资源抽象 - Resource、ResourceLoader 与远程资源扩展
1. 概述
在传统的 Java 开发中,访问资源的方式多种多样:java.io.File 用于文件系统、java.net.URL 用于网络资源、javax.imageio 用于图片资源。每种资源类型都对应不同的 API,缺乏统一的抽象层。当应用从文件系统迁移到类路径、或从本地部署迁移到云端时,资源访问代码往往需要大规模重构。
Spring Framework 通过 Resource 抽象 完美解决了这一问题。它定义了一套统一的资源访问接口,屏蔽了底层资源来源的差异,使得开发者可以用一致的方式访问文件系统、类路径、URL、Servlet 上下文等不同来源的资源。
本文基于 Spring Framework 5.3.x 源码,深入分析 Resource 接口体系、核心实现类、ResourceLoader 加载机制、资源模式解析,并结合实战案例展示如何扩展 Resource 支持阿里云 OSS / AWS S3 等远程存储。
2. Resource 接口体系
Spring 的资源抽象体系由多层接口构成,自顶向下逐渐丰富语义。
2.1 InputStreamSource —— 源头接口
InputStreamSource 是整个资源体系的源头,它只定义了一个方法:
package org.springframework.core.io;
public interface InputStreamSource {
InputStream getInputStream() throws IOException;
}该方法每次调用都应返回新的、未关闭的 InputStream。调用者负责在使用完毕后关闭流。这是整个资源读写能力的根基。
2.2 Resource —— 核心接口
Resource 继承了 InputStreamSource,扩展了资源描述的通用能力:
package org.springframework.core.io;
public interface Resource extends InputStreamSource {
// 资源是否存在(指向的资源可能已被移动或删除)
boolean exists();
// 是否可读(部分资源可能不可读,如目录)
default boolean isReadable() {
return exists();
}
// 是否已打开(如 InputStreamResource 一旦读取完毕就处于已打开状态)
default boolean isOpen() {
return false;
}
// 是否为文件系统资源
default boolean isFile() {
return false;
}
// 返回资源的 URL 句柄
URL getURL() throws IOException;
// 返回资源的 URI 句柄
URI getURI() throws IOException;
// 返回资源的 File 句柄(仅文件系统资源可用)
File getFile() throws IOException;
// 返回通道式 NIO ReadableByteChannel
default ReadableByteChannel readableChannel() throws IOException {
return Channels.newChannel(getInputStream());
}
// 资源内容长度
long contentLength() throws IOException;
// 资源最后修改时间戳
long lastModified() throws IOException;
// 根据相对路径创建子资源
Resource createRelative(String relativePath) throws IOException;
// 获取文件名
@Nullable
String getFilename();
// 资源描述(用于日志和错误消息)
String getDescription();
}Resource 接口的设计哲学是契约宽松、按需实现。例如 getFile() 方法对于 ClassPathResource 而言,在应用被打成 JAR 包后无法获取 File 实例,会抛出 FileNotFoundException;但 getInputStream() 始终可用。
2.3 WritableResource —— 可写资源
WritableResource 在 Resource 基础上增加了写入能力:
package org.springframework.core.io;
public interface WritableResource extends Resource {
default boolean isWritable() {
return true;
}
OutputStream getOutputStream() throws IOException;
default WritableByteChannel writableChannel() throws IOException {
return Channels.newChannel(getOutputStream());
}
}主要实现是 FileSystemResource 和 FileUrlResource(指向 file:// 协议的 URL 资源)。
2.4 ContextResource —— 上下文资源
ContextResource 标记了资源是从某个封闭上下文(如 ServletContext、Spring 应用上下文)中加载的:
package org.springframework.core.io;
public interface ContextResource extends Resource {
String getPathWithinContext();
}ServletContextResource 是其典型实现,getPathWithinContext() 返回相对于 Web 应用上下文的路径,如 /WEB-INF/applicationContext.xml。
2.5 接口体系类图
InputStreamSource
↑
Resource (exists, isOpen, getURL, getFile, contentLength, ...)
↑ ↑
WritableResource ContextResource
(getOutputStream) (getPathWithinContext)
↑
FileSystemResource (类,直接实现 WritableResource + Resource)3. Resource 核心实现类详解
3.1 ClassPathResource
用途:从类路径加载资源,支持 classpath: 协议。
核心特性:
- 内部持有
Class或ClassLoader引用,通过ClassLoader.getResource()或Class.getResource()查找资源 - 支持指定
Class作为加载基础,此时路径解析相对于该类的包路径
源码关键点:
public class ClassPathResource extends AbstractFileResolvingResource {
private final String path;
@Nullable
private ClassLoader classLoader;
@Nullable
private Class<?> clazz;
public ClassPathResource(String path) {
this(path, (ClassLoader) null);
}
public ClassPathResource(String path, @Nullable ClassLoader classLoader) {
Assert.notNull(path, "Path must not be null");
String pathToUse = StringUtils.cleanPath(path);
if (pathToUse.startsWith("/")) {
pathToUse = pathToUse.substring(1);
}
this.path = pathToUse;
this.classLoader = (classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader());
}
public ClassPathResource(String path, @Nullable Class<?> clazz) {
Assert.notNull(path, "Path must not be null");
this.path = StringUtils.cleanPath(path);
this.clazz = clazz;
}
@Override
public InputStream getInputStream() throws IOException {
InputStream is;
if (this.clazz != null) {
is = this.clazz.getResourceAsStream(this.path);
} else if (this.classLoader != null) {
is = this.classLoader.getResourceAsStream(this.path);
} else {
is = ClassLoader.getSystemResourceAsStream(this.path);
}
if (is == null) {
throw new FileNotFoundException(
"ClassLoader resource [" + this.path + "] cannot be resolved to absolute file path");
}
return is;
}
@Override
public URL getURL() throws IOException {
URL url = resolveURL();
if (url == null) {
throw new FileNotFoundException(
"ClassLoader resource [" + this.path + "] cannot be resolved to URL");
}
return url;
}
}使用注意:
- 路径不要以
/开头(StringUtils.cleanPath会自动去除开头的/) - JAR 包内的资源无法通过
getFile()获取,应使用getInputStream()或getURL() ClassPathResource继承自AbstractFileResolvingResource,在开发环境(IDE 或命令行)下getFile()可以正常工作
3.2 FileSystemResource
用途:操作文件系统上的资源,支持 file: 协议。
核心特性:
- 实现
WritableResource,同时支持读写 - 内部持有
java.io.File引用 - 路径可以是绝对路径或相对路径
public class FileSystemResource extends AbstractResource implements WritableResource {
private final File file;
private final String path;
public FileSystemResource(File file) {
Assert.notNull(file, "File must not be null");
this.file = file;
this.path = StringUtils.cleanPath(file.getPath());
}
public FileSystemResource(String path) {
Assert.notNull(path, "Path must not be null");
this.file = new File(path);
this.path = StringUtils.cleanPath(path);
}
@Override
public boolean exists() {
return this.file.exists();
}
@Override
public boolean isFile() {
return true;
}
@Override
public File getFile() {
return this.file;
}
@Override
public InputStream getInputStream() throws IOException {
return new FileInputStream(this.file);
}
@Override
public OutputStream getOutputStream() throws IOException {
return new FileOutputStream(this.file);
}
@Override
public long contentLength() throws IOException {
return this.file.length();
}
@Override
public long lastModified() throws IOException {
return this.file.lastModified();
}
@Override
public String getFilename() {
return this.file.getName();
}
@Override
public String getDescription() {
return "file [" + this.file.getAbsolutePath() + "]";
}
@Override
public Resource createRelative(String relativePath) throws IOException {
String relativeToPath = StringUtils.applyRelativePath(this.path, relativePath);
return new FileSystemResource(relativeToPath);
}
}FileSystemResource vs FileUrlResource:从 Spring 5.0 开始,DefaultResourceLoader 对 file: 协议前缀返回 FileSystemResource(而非以往的 UrlResource),这意味着 file: 资源现在直接拥有文件系统操作能力,如 getFile()、可写性等。
3.3 UrlResource
用途:包装 java.net.URL,支持任何 URL 协议(http://、https://、ftp://、file:// 等)。
核心特性:
- 本质是对
java.net.URL的适配器 - 通过
URL.openStream()获取输入流 - 继承
AbstractFileResolvingResource,对file://协议的 URL 可解析为 File
public class UrlResource extends AbstractFileResolvingResource {
@Nullable
private final URI uri;
private final URL url;
@Nullable
private final URL cleanedUrl;
public UrlResource(URI uri) throws MalformedURLException {
this.uri = uri;
this.url = uri.toURL();
this.cleanedUrl = getCleanedUrl(this.url, uri.toString());
}
public UrlResource(URL url) {
this.uri = null;
this.url = url;
this.cleanedUrl = getCleanedUrl(this.url, url.toString());
}
public UrlResource(String path) throws MalformedURLException {
this.uri = null;
this.url = new URL(path);
this.cleanedUrl = getCleanedUrl(this.url, path);
}
@Override
public InputStream getInputStream() throws IOException {
URLConnection con = this.url.openConnection();
ResourceUtils.useCachesIfNecessary(con);
try {
return con.getInputStream();
} catch (IOException ex) {
// 关闭 HTTP 连接(如果发生异常)
if (con instanceof HttpURLConnection) {
((HttpURLConnection) con).disconnect();
}
throw ex;
}
}
@Override
public URL getURL() {
return this.url;
}
@Override
public String getDescription() {
return "URL [" + this.url + "]";
}
}关闭 HTTP 连接的细节:UrlResource.getInputStream() 在发生 I/O 异常时,会主动释放 HttpURLConnection 资源,这是 Spring 在资源层面的健壮性设计。
3.4 ByteArrayResource
用途:包装内存中的字节数组,常作为测试桩或内存缓存使用。
public class ByteArrayResource extends AbstractResource {
private final byte[] byteArray;
private final String description;
public ByteArrayResource(byte[] byteArray) {
this(byteArray, "resource loaded from byte array");
}
public ByteArrayResource(byte[] byteArray, @Nullable String description) {
Assert.notNull(byteArray, "Byte array must not be null");
this.byteArray = byteArray;
this.description = (description != null ? description : "");
}
@Override
public boolean exists() {
return true;
}
@Override
public long contentLength() {
return this.byteArray.length;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(this.byteArray);
}
@Override
public String getDescription() {
return "Byte array resource [" + this.description + "]";
}
}注意:getInputStream() 每次返回基于同一字节数组的 ByteArrayInputStream,不会抛出 IOException。这在测试场景中非常便利。
3.5 InputStreamResource
用途:包装一个已打开的 InputStream。
public class InputStreamResource extends AbstractResource {
public static final String DESCRIPTION = "resource loaded through InputStream";
private final InputStream inputStream;
private final String description;
private boolean read = false;
public InputStreamResource(InputStream inputStream) {
this(inputStream, DESCRIPTION);
}
public InputStreamResource(InputStream inputStream, @Nullable String description) {
Assert.notNull(inputStream, "InputStream must not be null");
this.inputStream = inputStream;
this.description = (description != null ? description : "");
}
@Override
public boolean exists() {
return true;
}
@Override
public boolean isOpen() {
return true;
}
@Override
public InputStream getInputStream() throws IOException {
if (this.read) {
throw new IllegalStateException("InputStream has already been read - " +
"do not use InputStreamResource if a stream needs to be read multiple times");
}
this.read = true;
return this.inputStream;
}
}关键限制:
isOpen()返回true,不能重复读取- 流一旦被读取(
getInputStream()被调用),不可再次读取 - Spring 内部使用较少,通常在第三方库集成时使用
3.6 实现类对比表
| 实现类 | 资源来源 | 可读 | 可写 | isOpen() | getFile() | 典型协议 |
|---|---|---|---|---|---|---|
| ClassPathResource | 类路径 | ✓ | ✗ | false | 开发环境可用 | classpath: |
| FileSystemResource | 文件系统 | ✓ | ✓ | false | ✓ | file: |
| UrlResource | URL | ✓ | ✗ | false | file:协议可用 | http://, https://, ftp:// |
| ByteArrayResource | 内存字节数组 | ✓ | ✗ | false | ✗ | — |
| InputStreamResource | 已打开的流 | ✓ | ✗ | true | ✗ | — |
4. ResourceLoader —— 资源加载器
4.1 接口定义
ResourceLoader 是资源加载的核心抽象,它将资源的位置字符串与实际的 Resource 对象解耦:
package org.springframework.core.io;
public interface ResourceLoader {
/** Pseudo URL prefix for loading from the class path: "classpath:". */
String CLASSPATH_URL_PREFIX = ResourceUtils.CLASSPATH_URL_PREFIX;
// 核心方法:根据路径返回 Resource 实例
Resource getResource(String location);
// 返回当前 ResourceLoader 使用的 ClassLoader
@Nullable
ClassLoader getClassLoader();
}getResource(String location) 的路径解析规则(由 DefaultResourceLoader 实现):
| 路径前缀 | 返回的 Resource 类型 | 示例 |
|---|---|---|
classpath: | ClassPathResource | classpath:config/application.properties |
file: | FileSystemResource | file:/opt/config/app.properties |
http:// / https:// | UrlResource | https://example.com/config.json |
| 无前缀(标准 URL) | UrlResource | 如 /etc/config.properties(作为 URL 尝试解析) |
| 无前缀且不是标准 URL | 委托给 ResourceLoader 实现 | DefaultResourceLoader 返回 ClassPathResource |
4.2 DefaultResourceLoader 源码分析
public class DefaultResourceLoader implements ResourceLoader {
@Nullable
private ClassLoader classLoader;
// 协议处理器缓存:允许注册自定义协议处理器
private final Set<ProtocolResolver> protocolResolvers = new LinkedHashSet<>(4);
public DefaultResourceLoader() {
this.classLoader = ClassUtils.getDefaultClassLoader();
}
public DefaultResourceLoader(@Nullable ClassLoader classLoader) {
this.classLoader = classLoader;
}
@Override
public Resource getResource(String location) {
Assert.notNull(location, "Location must not be null");
// 第一优先:遍历自定义 ProtocolResolver
for (ProtocolResolver protocolResolver : getProtocolResolvers()) {
Resource resource = protocolResolver.resolve(location, this);
if (resource != null) {
return resource;
}
}
// 第二优先:classpath: 前缀 -> ClassPathResource
if (location.startsWith("/")) {
return getResourceByPath(location);
} else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader());
} else {
try {
// 第三优先:尝试作为 URL 解析
URL url = new URL(location);
return (ResourceUtils.isFileURL(url) ?
new FileSystemResource(ResourceUtils.getFile(url, getDescription())) :
new UrlResource(url));
} catch (MalformedURLException ex) {
// 不是有效的 URL -> 按普通路径处理
return getResourceByPath(location);
}
}
}
// 子类可覆盖此方法改变默认路径处理行为
protected Resource getResourceByPath(String path) {
return new ClassPathContextResource(path, getClassLoader());
}
}关键设计要点:
- 三级解析策略:自定义
ProtocolResolver→ 协议前缀匹配 → URL 兜底 ProtocolResolver扩展点:允许第三方注册自定义协议,如oss://、s3://等/开头的路径:DefaultResourceLoader将其作为ClassPathContextResource处理(路径相对于类路径根目录)
4.3 ProtocolResolver —— 协议扩展点
@FunctionalInterface
public interface ProtocolResolver {
@Nullable
Resource resolve(String location, ResourceLoader resourceLoader);
}ProtocolResolver 是 Spring 提供的 SPI 扩展点,允许在标准协议解析之前插入自定义逻辑。DefaultResourceLoader 持有 protocolResolvers 集合,遍历所有已注册的解析器。
注册方式:
DefaultResourceLoader loader = new DefaultResourceLoader();
loader.addProtocolResolver((location, resourceLoader) -> {
if (location.startsWith("oss://")) {
String objectPath = location.substring("oss://".length());
return new OssResource(objectPath);
}
return null;
});
Resource resource = loader.getResource("oss://my-bucket/config.properties");4.4 ResourceLoader 的实现者
ApplicationContext 都实现了 ResourceLoader 接口:
| 应用上下文 | 额外行为 |
|---|---|
ClassPathXmlApplicationContext | 从类路径加载配置,getResource 默认委托给 DefaultResourceLoader |
FileSystemXmlApplicationContext | 覆盖 getResourceByPath(),对无前缀路径返回 FileSystemResource |
WebApplicationContext | ServletContextResourceLoader 支持 "/WEB-INF/" 路径返回 ServletContextResource |
GenericApplicationContext | 内部使用 DefaultResourceLoader |
FileSystemXmlApplicationContext 的差异:
// FileSystemXmlApplicationContext 覆盖了 getResourceByPath
@Override
protected Resource getResourceByPath(String path) {
if (path.startsWith("/")) {
path = path.substring(1);
}
return new FileSystemResource(path);
}这意味着 FileSystemXmlApplicationContext 中传入 "/WEB-INF/config.xml"(无前缀且以 / 开头)会解析为文件系统上的 /WEB-INF/config.xml,而从 ClassPathXmlApplicationContext 中则会解析为类路径上的资源。
5. ResourcePatternResolver —— 资源批量解析
5.1 接口体系
ResourcePatternResolver 扩展了 ResourceLoader,增加了批量匹配能力:
package org.springframework.core.io.support;
public interface ResourcePatternResolver extends ResourceLoader {
// classpath*: 前缀常量
String CLASSPATH_ALL_URL_PREFIX = "classpath*:";
// 根据路径模式解析为一个或多个 Resource
Resource[] getResources(String locationPattern) throws IOException;
}5.2 PathMatchingResourcePatternResolver
这是 Spring 中默认使用的实现,支持以下特性:
- Ant 风格路径匹配:
?、*、**通配符 classpath*:前缀:扫描所有 JAR 包中的匹配资源- 文件系统扫描:对
file://协议执行递归文件遍历 - JAR 包扫描:通过
JarFile枚举 JAR 内的条目
核心工作流程(getResources 方法):
@Override
public Resource[] getResources(String locationPattern) throws IOException {
Assert.notNull(locationPattern, "Location pattern must not be null");
// 以 classpath*: 开头
if (locationPattern.startsWith(CLASSPATH_ALL_URL_PREFIX)) {
// 路径包含通配符 -> 遍历扫描
if (getPathMatcher().isPattern(locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()))) {
return findAllClassPathResources(
locationPattern.substring(CLASSPATH_ALL_URL_PREFIX.length()));
}
// 精确路径 -> 直接加载(获取所有 JAR 包中的匹配资源)
else {
return findAllClassPathResources(locationPattern);
}
}
// 其他情况
else {
int prefixEnd = locationPattern.indexOf(':') + 1;
// 路径包含通配符
if (getPathMatcher().isPattern(locationPattern.substring(prefixEnd))) {
return findPathMatchingResources(locationPattern);
}
// 精确路径 -> 委托给 ResourceLoader
else {
return new Resource[] {getResourceLoader().getResource(locationPattern)};
}
}
}5.3 classpath*: 加载原理
classpath*: 与 classpath: 的核心差异在于:
classpath::使用ClassLoader.getResource(),返回第一个匹配的资源classpath*::使用ClassLoader.getResources(),返回所有 JAR 包中的匹配资源
protected Resource[] findAllClassPathResources(String location) throws IOException {
String path = location;
if (path.startsWith("/")) {
path = path.substring(1);
}
// 关键:getResources() 而非 getResource()
Enumeration<URL> resourceUrls = getClassLoader().getResources(path);
Set<Resource> result = new LinkedHashSet<>(16);
while (resourceUrls.hasMoreElements()) {
URL url = resourceUrls.nextElement();
result.add(convertClassLoaderURL(url));
}
return result.toArray(new Resource[0]);
}5.4 Ant 风格路径匹配详解
PathMatchingResourcePatternResolver 默认使用 AntPathMatcher 进行模式匹配,支持以下通配符:
| 通配符 | 含义 | 示例 |
|---|---|---|
? | 匹配单个字符 | config?.properties → config1.properties |
* | 匹配路径段内的零个或多个字符 | /*.properties → /app.properties |
** | 匹配任意级数的路径 | /**/*.properties → /a/b/c/app.properties |
常见模式示例:
// 扫描类路径根目录下所有 properties 文件(多 JAR 包)
Resource[] resources = resolver.getResources("classpath*:*.properties");
// 扫描所有 META-INF 目录下的 spring 配置文件
Resource[] resources = resolver.getResources("classpath*:META-INF/spring-*.xml");
// 文件系统上递归扫描
Resource[] resources = resolver.getResources("file:/opt/config/**/*.yaml");扫描 JAR 包内资源:当路径模式匹配到 JAR 包(如 jar: 协议)时,PathMatchingResourcePatternResolver 会通过 JarFile 获取 JarEntry 列表,逐条匹配路径模式:
// 处理 jar:file:/path/to/lib/example.jar!/META-INF/ 这样的 URL
protected Set<Resource> doFindPathMatchingJarResources(
URL jarUrl, String rootDirPath, String subPattern) throws IOException {
JarFile jarFile = null;
try {
jarFile = ((JarURLConnection) jarUrl.openConnection()).getJarFile();
Set<Resource> result = new LinkedHashSet<>();
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryPath = entry.getName();
if (entryPath.startsWith(rootDirPath) && !entry.isDirectory()) {
String relativePath = entryPath.substring(rootDirPath.length());
if (getPathMatcher().match(subPattern, relativePath)) {
result.add(new UrlResource(jarUrl, rootDirPath + relativePath));
}
}
}
return result;
} finally {
if (jarFile != null) {
jarFile.close();
}
}
}6. 编码处理 —— EncodedResource
6.1 EncodedResource 设计
EncodedResource 是一个装饰器(Decorator),它包装一个 Resource 并为其关联字符编码信息:
package org.springframework.core.io.support;
public class EncodedResource implements InputStreamSource {
private final Resource resource;
@Nullable
private final String encoding;
@Nullable
private final Charset charset;
public EncodedResource(Resource resource) {
this(resource, (String) null);
}
public EncodedResource(Resource resource, @Nullable String encoding) {
Assert.notNull(resource, "Resource must not be null");
this.resource = resource;
this.encoding = encoding;
this.charset = (encoding != null ? Charset.forName(encoding) : null);
}
public EncodedResource(Resource resource, @Nullable Charset charset) {
Assert.notNull(resource, "Resource must not be null");
this.resource = resource;
this.encoding = (charset != null ? charset.name() : null);
this.charset = charset;
}
public Resource getResource() {
return this.resource;
}
@Nullable
public String getEncoding() {
return this.encoding;
}
@Nullable
public Charset getCharset() {
return this.charset;
}
// 返回带编码的 Reader
@Override
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());
}
}
}6.2 使用场景
EncodedResource 主要在 Spring 的核心组件中被使用:
1. PropertySource 加载:
// PropertiesLoaderSupport 中使用 EncodedResource 加载属性文件
EncodedResource encodedResource = new EncodedResource(resource, "UTF-8");
try (Reader reader = encodedResource.getReader()) {
props.load(reader);
}2. ResourceDatabasePopulator(SQL 脚本执行):
EncodedResource encodedResource = new EncodedResource(resource, charset);
ScriptUtils.executeSqlScript(connection, encodedResource);3. 手动使用示例:
Resource resource = new ClassPathResource("messages.properties");
// 指定 UTF-8 编码读取
EncodedResource encodedResource = new EncodedResource(resource, StandardCharsets.UTF_8);
String content = FileCopyUtils.copyToString(encodedResource.getReader());7. Resource 在 Spring 中的使用场景
7.1 @PropertySource 加载配置文件
@PropertySource 注解背后依赖 Resource 抽象加载属性文件:
@Configuration
@PropertySource(
value = {
"classpath:config/database.properties",
"file:${external.config.path}/app.properties",
"classpath:config/i18n/messages_${locale:zh_CN}.properties"
},
encoding = "UTF-8"
)
public class AppConfig {
@Value("${db.url}")
private String dbUrl;
@Value("${db.username}")
private String dbUsername;
}底层实现——PropertySourcesLoader 通过 ResourceLoader.getResource() 解析路径,然后用 EncodedResource 包装并读取:
// PropertySourceLoader 实现的核心逻辑
Resource resource = resourceLoader.getResource(location);
if (resource.exists()) {
EncodedResource encodedResource = new EncodedResource(resource, encoding);
Properties props = PropertiesLoaderUtils.loadProperties(encodedResource);
// ... 注册到 Environment
}7.2 MessageSource 加载国际化文件
Spring 的 ResourceBundleMessageSource 和 ReloadableResourceBundleMessageSource 使用 Resource 加载国际化资源:
@Bean
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
// 支持 classpath: 和 file: 协议
messageSource.setBasename("classpath:i18n/messages");
messageSource.setDefaultEncoding("UTF-8");
messageSource.setCacheSeconds(3600);
return messageSource;
}ReloadableResourceBundleMessageSource 内部实现:
// 简化后的加载逻辑
protected PropertiesHolder refreshProperties(String filename, PropertiesHolder propHolder) {
// 根据 basename 构建资源路径
String path = filename + "_" + locale.toString() + ".properties";
Resource resource = getResourceLoader().getResource(path);
if (resource.exists()) {
EncodedResource encodedResource = new EncodedResource(resource, this.defaultEncoding);
Properties props = PropertiesLoaderUtils.loadProperties(encodedResource);
return new PropertiesHolder(props, resource.lastModified());
}
return null;
}7.3 @Value 注入 Resource
Spring 支持通过 @Value 直接将资源注入到字段中:
@Component
public class ResourceInjectDemo {
@Value("classpath:config/application.properties")
private Resource applicationProperties;
@Value("file:/opt/config/external-config.yaml")
private Resource externalConfig;
@Value("https://raw.githubusercontent.com/spring-projects/spring-framework/main/README.md")
private Resource remoteReadme;
public void printContent() throws IOException {
// 统一使用 InputStream 读取,无需关心资源来源
try (InputStream is = applicationProperties.getInputStream()) {
String content = new String(is.readAllBytes(), StandardCharsets.UTF_8);
System.out.println("Content: " + content);
}
}
}实现原理——ResourceEditor 或 ResourcePropertyEditor 将字符串转换为 Resource 对象:
public class ResourceEditor extends PropertyEditorSupport {
private final ResourceLoader resourceLoader;
public ResourceEditor() {
this(new DefaultResourceLoader());
}
public ResourceEditor(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
@Override
public void setAsText(String text) {
setValue(this.resourceLoader.getResource(text));
}
}Spring 在 AutowireCapableBeanFactory 初始化 Bean 时,检测到目标类型为 Resource,就使用 ResourceEditor 对 @Value 中的字符串进行转换。
7.4 ResourceLoaderAware —— 获取 ResourceLoader
@Component
public class ResourceLoadingService implements ResourceLoaderAware {
private ResourceLoader resourceLoader;
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
public String loadConfig(String path) throws IOException {
Resource resource = resourceLoader.getResource(path);
try (InputStream is = resource.getInputStream()) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
}ApplicationContext 实现了 ResourceLoaderAware 的自动回调,容器会在初始化 Bean 时自动注入自身(因为 ApplicationContext 实现了 ResourceLoader)。
7.5 在 RestTemplate 中使用
// 下载文件到字节数组
Resource resource = restTemplate.getForObject(
"https://example.com/files/report.xlsx",
Resource.class
);
byte[] data = FileCopyUtils.copyToByteArray(resource.getInputStream());RestTemplate 的 HttpMessageConverter 能够自动将 HTTP 响应转换为 Resource 对象,尤其是 ByteArrayResource。
8. 实战案例:扩展 Resource 支持远程对象存储
本节展示如何通过 Spring 的 ProtocolResolver 扩展点和自定义 Resource 实现,集成阿里云 OSS 和 AWS S3。
8.1 统一抽象设计
/**
* 远程对象存储资源 - 抽象基类
*/
public abstract class RemoteStorageResource extends AbstractResource {
protected final String bucketName;
protected final String objectKey;
public RemoteStorageResource(String bucketName, String objectKey) {
this.bucketName = bucketName;
this.objectKey = objectKey;
}
@Override
public String getFilename() {
return objectKey.substring(objectKey.lastIndexOf('/') + 1);
}
@Override
public long contentLength() throws IOException {
return getObjectMetadata().getContentLength();
}
@Override
public long lastModified() throws IOException {
return getObjectMetadata().getLastModified().toEpochMilli();
}
@Override
public String getDescription() {
return getProtocol() + "://" + bucketName + "/" + objectKey;
}
public abstract String getProtocol();
protected abstract ObjectMetadata getObjectMetadata() throws IOException;
/**
* 对象元数据封装
*/
public static class ObjectMetadata {
private final long contentLength;
private final Instant lastModified;
public ObjectMetadata(long contentLength, Instant lastModified) {
this.contentLength = contentLength;
this.lastModified = lastModified;
}
public long getContentLength() { return contentLength; }
public Instant getLastModified() { return lastModified; }
}
}8.2 阿里云 OSS 实现
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.OSSObject;
import com.aliyun.oss.model.ObjectMetadata;
public class OssResource extends RemoteStorageResource {
private final String endpoint;
private final String accessKeyId;
private final String accessKeySecret;
public OssResource(String endpoint, String bucketName, String objectKey,
String accessKeyId, String accessKeySecret) {
super(bucketName, objectKey);
this.endpoint = endpoint;
this.accessKeyId = accessKeyId;
this.accessKeySecret = accessKeySecret;
}
public OssResource(String endpoint, String bucketName, String objectKey) {
this(endpoint, bucketName, objectKey, null, null);
}
@Override
public String getProtocol() {
return "oss";
}
@Override
public InputStream getInputStream() throws IOException {
OSS ossClient = createOSSClient();
try {
OSSObject ossObject = ossClient.getObject(bucketName, objectKey);
// 注意:此处流关闭需要与 OSSClient 生命周期协同,生产环境建议使用连接池管理
return ossObject.getObjectContent();
} catch (Exception e) {
ossClient.shutdown();
throw new IOException("Failed to fetch OSS object: " + getDescription(), e);
}
}
@Override
protected ObjectMetadata getObjectMetadata() throws IOException {
OSS ossClient = createOSSClient();
try {
ObjectMetadata metadata = ossClient.getObjectMetadata(bucketName, objectKey);
return new ObjectMetadata(
metadata.getContentLength(),
metadata.getLastModified().toInstant()
);
} catch (Exception e) {
throw new IOException("Failed to get OSS metadata: " + getDescription(), e);
} finally {
ossClient.shutdown();
}
}
private OSS createOSSClient() {
return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
}
}8.3 AWS S3 实现
import software.amazon.awssdk.core.ResponseInputStream;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
public class S3Resource extends RemoteStorageResource {
private final S3Client s3Client;
private final String region;
public S3Resource(S3Client s3Client, String bucketName, String objectKey) {
super(bucketName, objectKey);
this.s3Client = s3Client;
this.region = null;
}
public S3Resource(String region, String bucketName, String objectKey) {
super(bucketName, objectKey);
this.region = region;
this.s3Client = null;
}
@Override
public String getProtocol() {
return "s3";
}
private S3Client getS3Client() {
if (this.s3Client != null) {
return this.s3Client;
}
return S3Client.builder()
.region(software.amazon.awssdk.regions.Region.of(this.region))
.build();
}
@Override
public InputStream getInputStream() throws IOException {
S3Client client = getS3Client();
try {
GetObjectRequest request = GetObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
ResponseInputStream<GetObjectResponse> s3Object = client.getObject(request);
return s3Object;
} catch (Exception e) {
throw new IOException("Failed to fetch S3 object: " + getDescription(), e);
}
}
@Override
protected ObjectMetadata getObjectMetadata() throws IOException {
S3Client client = getS3Client();
try {
HeadObjectRequest request = HeadObjectRequest.builder()
.bucket(bucketName)
.key(objectKey)
.build();
HeadObjectResponse response = client.headObject(request);
return new ObjectMetadata(
response.contentLength(),
response.lastModified()
);
} catch (Exception e) {
throw new IOException("Failed to get S3 metadata: " + getDescription(), e);
}
}
}8.4 注册 ProtocolResolver
方式一:直接在 DefaultResourceLoader 中注册
DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
// 注册 OSS 协议解析器
resourceLoader.addProtocolResolver((location, loader) -> {
if (location.startsWith("oss://")) {
// oss://my-bucket/path/to/file.properties
String remaining = location.substring("oss://".length());
int slashIndex = remaining.indexOf('/');
String bucket = remaining.substring(0, slashIndex);
String key = remaining.substring(slashIndex + 1);
return new OssResource("https://oss-cn-hangzhou.aliyuncs.com", bucket, key);
}
return null;
});
// 注册 S3 协议解析器(使用工厂方法创建带 Region 的 S3Client)
S3Client s3Client = S3Client.builder()
.region(software.amazon.awssdk.regions.Region.US_EAST_1)
.build();
resourceLoader.addProtocolResolver((location, loader) -> {
if (location.startsWith("s3://")) {
// s3://my-bucket/path/to/file.properties
String remaining = location.substring("s3://".length());
int slashIndex = remaining.indexOf('/');
String bucket = remaining.substring(0, slashIndex);
String key = remaining.substring(slashIndex + 1);
return new S3Resource(s3Client, bucket, key);
}
return null;
});
// 使用
Resource ossResource = resourceLoader.getResource("oss://my-app-bucket/config/db.properties");
Resource s3Resource = resourceLoader.getResource("s3://my-app-bucket/config/app.properties");方式二:结合 Spring Boot 自动配置(推荐)
@Configuration
public class RemoteStorageResourceConfig {
@Value("${oss.endpoint}")
private String ossEndpoint;
@Bean
public ProtocolResolver ossProtocolResolver() {
return (location, resourceLoader) -> {
if (location.startsWith("oss://")) {
String remaining = location.substring("oss://".length());
int slashIndex = remaining.indexOf('/');
String bucket = remaining.substring(0, slashIndex);
String key = remaining.substring(slashIndex + 1);
return new OssResource(ossEndpoint, bucket, key);
}
return null;
};
}
@Bean
public ProtocolResolver s3ProtocolResolver(S3Client s3Client) {
return (location, resourceLoader) -> {
if (location.startsWith("s3://")) {
String remaining = location.substring("s3://".length());
int slashIndex = remaining.indexOf('/');
String bucket = remaining.substring(0, slashIndex);
String key = remaining.substring(slashIndex + 1);
return new S3Resource(s3Client, bucket, key);
}
return null;
};
}
/**
* 将自定义 ProtocolResolver 注入到 ApplicationContext 的 ResourceLoader 中
*/
@Bean
public BeanPostProcessor resourceLoaderPostProcessor(
List<ProtocolResolver> resolvers) {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof DefaultResourceLoader) {
DefaultResourceLoader loader = (DefaultResourceLoader) bean;
resolvers.forEach(loader::addProtocolResolver);
}
return bean;
}
};
}
}方式三:全局注册到 Spring 容器
在 META-INF/spring.factories 中声明(Spring Boot 自动配置方式):
# META-INF/spring.factories
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.example.RemoteStorageResourceAutoConfiguration@Configuration
public class RemoteStorageResourceAutoConfiguration implements ResourceLoaderAware {
private DefaultResourceLoader resourceLoader;
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
if (resourceLoader instanceof DefaultResourceLoader) {
this.resourceLoader = (DefaultResourceLoader) resourceLoader;
registerProtocolResolvers();
}
}
private void registerProtocolResolvers() {
// 注册 OSS 协议
resourceLoader.addProtocolResolver((location, loader) -> {
if (location.startsWith("oss://")) {
// ... 解析逻辑
}
return null;
});
// 注册 S3 协议
resourceLoader.addProtocolResolver((location, loader) -> {
if (location.startsWith("s3://")) {
// ... 解析逻辑
}
return null;
});
}
}8.5 使用示例
@Configuration
@PropertySource("oss://my-bucket/config/application.properties")
public class RemoteConfigApp {
@Value("oss://my-bucket/config/db.properties")
private Resource dbConfig;
@Autowired
private ResourceLoader resourceLoader;
public void loadRemoteResource() throws IOException {
// 统一通过 InputStream 读取
Resource remoteResource = resourceLoader.getResource("oss://my-bucket/data/template.xlsx");
try (InputStream is = remoteResource.getInputStream()) {
Workbook workbook = WorkbookFactory.create(is);
// ... 处理 Excel
}
// 或读取 S3 上的资源
Resource s3Resource = resourceLoader.getResource("s3://my-bucket/reports/report.pdf");
byte[] content = FileCopyUtils.copyToByteArray(s3Resource.getInputStream());
}
}9. 常见问题与最佳实践
9.1 getFile() vs getInputStream()
| API | 适用场景 | 限制 |
|---|---|---|
getInputStream() | 所有场景的首选 | 无 |
getFile() | 仅当需要 RandomAccessFile、FileChannel 或文件路径参数时使用 | 不支持 JAR 包内资源、不支持远程资源 |
黄金法则:除非明确需要 File 对象(如为第三方库传递文件路径),否则始终使用 getInputStream()。
9.2 classpath: 与 classpath*: 的选择
| 前缀 | 行为 | 使用场景 |
|---|---|---|
classpath: | 返回第一个匹配的资源 | 精确路径,确定只有一个资源 |
classpath*: | 返回所有匹配的资源(跨 JAR) | 扫描模式,或有多个 JAR 包含同名文件时 |
注意:classpath*: 与 Ant 路径模式结合时,底层通过 ClassLoader.getResources() 枚举所有 URL,然后逐一检查。对于包含通配符的路径,Spring 需要遍历 JAR 包的所有条目,性能上可能比 classpath: 差一些。
9.3 Resource 的内存管理
// ✅ 正确:try-with-resources 自动关闭
try (InputStream is = resource.getInputStream()) {
// 处理流
}
// ❌ 错误:忘记关闭流
InputStream is = resource.getInputStream();
// 没有关闭9.4 Resource 的 equals 和 hashCode
AbstractResource 提供了基于 getDescription() 的 equals/hashCode 实现:
@Override
public boolean equals(@Nullable Object other) {
return (this == other || (other instanceof Resource &&
((Resource) other).getDescription().equals(getDescription())));
}
@Override
public int hashCode() {
return getDescription().hashCode();
}注意:这要求 getDescription() 实现返回确定的、可比较的字符串。对于 ByteArrayResource,默认的 getDescription() 返回固定描述,两个内容不同的 ByteArrayResource 可能被判定为相等。在需要按值比较的场景中,应考虑覆写 equals/hashCode。
10. 总结
Spring 的 Resource 抽象是框架中最基础但也是最实用的设计之一。通过六级接口体系(InputStreamSource → Resource → WritableResource → ContextResource)和丰富的实现类,它成功屏蔽了不同资源来源的差异,为上层组件提供了统一的资源访问视图。
核心设计要点回顾:
- 策略模式:
ResourceLoader根据路径前缀委派给不同的Resource实现 - 扩展性:
ProtocolResolverSPI 允许任意自定义协议,如oss://、s3:// - 批量扫描:
PathMatchingResourcePatternResolver配合 Ant 路径模式和classpath*:前缀,实现了跨 JAR 包的资源发现 - 编码支持:
EncodedResource装饰器为资源添加字符编码语义
理解 Resource 抽象不仅有助于日常开发,也为深入理解 Spring 内部机制(如配置加载、国际化、消息处理等)奠定了坚实基础。当面临新的资源来源时(如云存储、分布式文件系统),继承 AbstractResource 并注册 ProtocolResolver 是最佳的集成方式。