说是“常用方法”,其实这个坑我踩了不少次。很多同学刚开始接触Spring Boot,发现代码里用new File("src/main/resources/xxx")在本地跑得好好的,一打成jar丢到服务器上就崩,各种FileNotFoundException冒出来。实际上在Spring Boot项目里读resources目录下的文件,严格意义上讲有“读文件系统路径”和“读classpath资源”两套完全不同的玩法,选错了方案,轻则开发环境正常、生产环境报错,重则直接启动失败。这篇文章我会把几种主流方式全部梳理一遍,把每个方案背后的原理、适用场景、坑点都讲透,并给出可以直接抄的代码。
适合正在做Spring Boot项目、尤其是需要把代码打包部署的开发者参考。不管你是刚接触Java两三周的新手,还是被jar包资源问题折磨过的老手,这篇文章都能帮你少走弯路。
1. 整体思路拆解:为什么读取resources目录会有这么多讲究
1.1 本地文件系统和classpath的本质差异
先理清一个核心概念。resources目录(通常位于src/main/resources)在编译后会被放到classes目录下,最终被打进jar包内的根路径。也就是说,当你运行Spring Boot应用时,这个目录不一定还以“目录”的形式存在,它可能是一个jar包里的逻辑路径。
如果在IDE里直接运行main方法,target/classes确实是一个真实目录,这时用new File("src/main/resources/xxx")也常常能跑通。但问题在于:第一,src/main/resources是源码目录,运行时的classpath目录其实是target/classes,两者的相对路径完全不同;第二,用File读取一旦进入jar包,就会直接报错,因为jar内部不是一个操作系统可遍历的目录。
所以正确思路是:始终把resources下的文件当作“classpath资源”来读取,而不是当作“文件系统路径”来读取。只有明确知道外部配置目录可以覆盖jar内资源时,才需要借助外部路径。
1.2 不同场景下的方案选型
根据实际需求,我一般把获取resources文件分成四类场景:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 读取配置文件、模板、静态资源内容 | ClassPathResource+getInputStream() | 不依赖真实文件路径,兼容IDE和jar包运行 |
| 需要获取单个资源的URL/URI | ClassPathResource.getURL()或getResource() | 可以拿到标准的资源定位符 |
| 批量遍历resources下的多个文件 | 扫描classpath路径 | File遍历在jar包内不可靠,需要专门的扫描方案 |
| 读取外部自定义配置覆盖jar内部配置 | spring.config.location或自定义路径 | 便于运维修改 |
需要注意的是,不要一上来就写FileInputStream。Spring Boot的核心运行机制是内嵌容器打包,几乎所有内部资源都应该流式访问。这和你做普通Java桌面程序是有显著区别的。
2. 核心细节解析:InputStream、URL与File之间的区别
2.1 为什么优先用InputStream而不是File
Java里读取文件最直观的方式是new File("/路径/文件名"),然后配合FileInputStream去读取。但File对象代表的是操作系统中的真实文件路径,它要求“这个路径必须存在且能直接访问”。而resources目录经过构建之后,会被打包进jar成为归档文件内部条目,它没有真实路径。所以必须使用InputStream这种流式抽象来读取资源内容。
打个比方:File模式像你拿着门牌号去找一个实体房间;InputStream模式像你通过一个输送管道接收传送过来的物品,不管物品原来存储在实体房间还是仓库集装箱(jar包),你都能收到。在Spring Boot里,只要资源在classpath下,InputStream模式永远比File模式可靠。
2.2 ClassPathResource的定位机制
Spring框架提供ClassPathResource,它的定位依赖于ClassLoader。ClassPathResource("templates/xxx.html")会通过加载该资源的ClassLoader去classpath下寻找对应路径。关键在于路径写法:最前面不要带/。很多人习惯性写成/templates/xxx.html,这在某些ClassLoader.getResource()实现里也能识别,但ClassPathResource的语义是classpath根下的相对路径,规范写法是不带前导斜杠。
还有一个隐藏细节:如果资源文件在jar包内,ClassPathResource.getFile()会抛出FileNotFoundException,因为jar包内的资源不是文件系统文件。这时只能用getInputStream(),或者用getURL()拿到jar:开头的URL。很多初学者看到这个异常,第一反应是“路径写错了”,其实是访问方式错了。
2.3 ResourceLoader与ApplicationContext的统一抽象
Spring Boot项目中,ApplicationContext本身就是一个ResourceLoader,所以可以直接注入ResourceLoader或ApplicationContext来调用getResource()。这种方式的优势在于资源类型是开放的:你写classpath:templates/xxx.html它就会按classpath解析,写file:/外部路径/xxx.json它就能按文件系统解析。它是策略模式的经典应用,内部会根据前缀选择不同的Resource实现。
实际操作中,我推荐在Spring管理的Bean中注入ResourceLoader,而不是直接硬编码ClassPathResource。这样做的好处是代码更具通用性,将来如果某个文件从resources迁移到外部目录,只需要改资源前缀,不需要动业务逻辑。
3. 实操过程与核心环节实现
3.1 方式一:使用ClassPathResource读取单个文件
这是最基础也最推荐的方式。直接看代码:
import org.springframework.core.io.ClassPathResource; import java.io.InputStream; import java.nio.charset.StandardCharsets; public String readConfigFromResource() { ClassPathResource resource = new ClassPathResource("config/app-config.json"); try (InputStream inputStream = resource.getInputStream()) { return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("读取resources配置失败", e); } }这里有几个重点。
第一个重点:路径config/app-config.json对应的是src/main/resources/config/app-config.json,编译后位于classpath根下的config子目录,所以不能写成resources/config/app-config.json。这个resources前缀是源码目录名,不是classpath路径的一部分。
第二个重点:readAllBytes()是JDK 9引入的,如果你的项目还在用Java 8,需要改用循环读取或者Apache Commons IO的IOUtils.toString(inputStream, "UTF-8")。很多公司的老项目还在JDK 8上,这点要特别注意。
第三个重点是资源关闭。这里用了try-with-resources,InputStream会自动关闭。千万不能图省事只调getInputStream()而不关闭流,生产环境跑久了会出现文件句柄泄漏。
3.2 方式二:使用ResourceLoader/ApplicationContext获取资源
在Spring Boot的自动配置环境中,ResourceLoader可以直接注入。看代码:
@Service public class ResourceReaderService { private final ResourceLoader resourceLoader; public ResourceReaderService(ResourceLoader resourceLoader) { this.resourceLoader = resourceLoader; } public String readFile(String path) throws IOException { Resource resource = resourceLoader.getResource("classpath:" + path); try (InputStream is = resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } }注意这里注入的是ResourceLoader,Spring会自动传入ApplicationContext实例。调用时:
String content = resourceReaderService.readFile("data/demo.txt");getResource()返回的Resource接口有多个实现,具体返回哪个由前缀决定:
| 前缀 | 示例 | Resource实现 |
|---|---|---|
classpath: | classpath:data/demo.txt | ClassPathResource |
file: | file:/opt/config/demo.txt | FileUrlResource |
url: | url:http://example.com/demo.txt | UrlResource |
| 无前缀 | data/demo.txt | 根据ApplicationContext类型决定 |
如果调用时没加前缀,比如getResource("data/demo.txt"),在Spring Boot的ServletWebServerApplicationContext里通常会解析为ClassPathContextResource,但它和显式写classpath:还是有细微差别。最稳妥的写法是显式加前缀,避免歧义。
3.3 方式三:使用ClassLoader直接获取流
Spring Boot项目里,你也可以不借助Spring的Resource体系,直接用ClassLoader。写法如下:
public String readFileByClassLoader(String path) throws IOException { // 不带前导斜杠 ClassLoader classLoader = getClass().getClassLoader(); try (InputStream is = classLoader.getResourceAsStream(path)) { if (is == null) { throw new IllegalArgumentException("资源不存在: " + path); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } }这里必须强调:getResourceAsStream参数不要以/开头。因为ClassLoader.getResourceAsStream()规范要求传的是相对于classpath根部的名称,前导斜杠会导致部分容器解析失败。虽然有些版本能兼容,但这属于未定义行为,不能依赖。
有人会问,那为什么网上很多代码用了Thread.currentThread().getContextClassLoader()?在复杂的Web应用里,线程上下文类加载器可以加载应用自己的资源。如果你的类是被自定义类加载器加载的,用getClass().getClassLoader()和用线程上下文类加载器结果可能不一样。在Spring Boot常规项目中,两者往往都能工作,但规范起见,我一般会优先使用getClass().getClassLoader(),因为getClass()定位到的类加载器和你当前代码的资源是同一个来源,语义最清晰。
3.4 方式四:直接通过Class.getResourceAsStream获取
还有一类写法,用类名.class.getResourceAsStream()。注意它与ClassLoader版本的区别:
public String readFileByClass(String path) throws IOException { // 第一种:带前导斜杠,表示从classpath根开始 try (InputStream is = getClass().getResourceAsStream("/config/app-config.json")) { ... } // 第二种:不带前导斜杠,表示和当前类所在包目录相对 try (InputStream is = getClass().getResourceAsStream("config/app-config.json")) { ... } }Class.getResourceAsStream()和ClassLoader.getResourceAsStream()的区别在于:前者如果传/config/app-config.json,等价于调用者所在的ClassLoader上的getResourceAsStream("config/app-config.json"),也就是自动去掉前导斜杠;如果传config/app-config.json,则会在当前类所在的包路径下找,比如当前类在com/example/demo包下,它就会去找com/example/demo/config/app-config.json,这往往不是你想要的文件。
所以如果你想用Class.getResourceAsStream()从classpath根读取,就必须加/。这与ClassLoader版本 反过来,非常容易搞混。
3.5 方式五:读取resources下的图片、模板等二进制资源
对于图片、Excel模板、PDF模板这类二进制文件,流程也是一样的。看一个例子,读取模板文件并复制到输出流:
public void copyTemplateToResponse(String templateName, OutputStream output) throws IOException { ClassPathResource resource = new ClassPathResource("templates/" + templateName); try (InputStream input = resource.getInputStream()) { byte[] buffer = new byte[4096]; int length; while ((length = input.read(buffer)) != -1) { output.write(buffer, 0, length); } output.flush(); } }这里推荐用缓冲读取,因为二进制文件可能比较大,一次性readAllBytes()会把整个文件加载进内存。如果模板是几MB甚至几十MB,一次读进内存会导致内存抖动。凡是涉及大文件的场景,都要用流式拷贝。
3.6 方式六:打jar包后依然能用的资源路径写法
很多人在本地运行没问题,打完包就崩。原因就是他把resources路径写成了File路径。正确的打包后运行方式是:
运行java -jar demo.jar时,jar包内的资源是归档条目,需要用ClassPathResource或ClassLoader读取。例如读取BOOT-INF/classes/下的文件,在Spring Boot的fat jar里,ClassPathResource("templates/xxx.html")可以自动定位到BOOT-INF/classes/templates/xxx.html,因为Spring Boot的LaunchedURLClassLoader做了特殊处理。
但如果直接用标准的Java的URL.getResource()在某些非Spring容器环境里读取fat jar内部资源,可能拿不到正确路径。所以Spring Boot项目里读取resources,优先用Spring抽象,不要直接操作jar:file:URL去解析,容易踩到嵌套jar的坑。
3.7 方式七:外部配置目录覆盖resources文件的方案
有时候你希望jar包外的配置能覆盖resources里的默认配置。针对这个场景,Spring Boot本身就支持配置路径覆盖。一种比较灵活的做法是:
@Value("${custom.config.location:classpath:config/default.json}") private String configLocation; public String loadConfig() throws IOException { Resource resource = new DefaultResourceLoader().getResource(configLocation); try (InputStream is = resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } }在application.yml里配置:
custom: config: location: file:/opt/conf/custom.json这样程序启动时如果file:路径存在,就会读取外部配置;如果不存在,可以回退到classpath下的默认配置。而且DefaultResourceLoader.getResource()支持file:、classpath:等前缀自动适配,避免了手动判断文件路径。
不过需要注意,file:/opt/conf/custom.json要求外部文件必须真实存在。如果文件不存在,流会抛异常。你可以在配置类里处理一下回退逻辑,比如先判断外部文件是否存在。
4. 常见问题与排查技巧实录
4.1 提示“FileNotFoundException: class path resource [...] cannot be opened”
这个报错是最常见的。排查思路很简单:确认路径是否写对了。检查目标文件是否确实在src/main/resources下,确认编译后的target/classes里是否存在对应文件。如果IDE没有自动重新编译,可能需要先mvn clean compile,因为增量编译可能没有复制新增资源。另一个高概率原因是路径多了前缀,比如resources/config/app.json就是错误的,正确写法是config/app.json。
4.2 本地正常,打包后读取File报错
典型异常是:
java.io.FileNotFoundException: /xxx/BOOT-INF/classes/templates/template.html (No such file or directory)这代表你用了new File("BOOT-INF/classes/...")或类似文件系统路径访问jar内部资源。jar内的资源不是操作系统的实际文件,File肯定找不到。解决方法就是改成像第3章演示的getInputStream()。
如果你拿到的是ClassPathResource,也千万不要调resource.getFile(),而要用getInputStream()。
4.3 读取JSON/XML配置文件时中文乱码
中文乱码八成是编码问题。代码里读流后转字符串时,一定要显式指定StandardCharsets.UTF_8。很多系统环境默认编码是GBK,如果你直接new String(bytes),在中文环境下会按系统默认编码解码,就会出现乱码。顺便提一下,你的源文件本身也要存成UTF-8,Maven配置里最好加上统一的字符集设置:
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties>4.4 路径分隔符不统一
在Windows下,资源路径里出现了反斜杠\,比如config\app.json,在某些实现里还能兼容,但规范做法是一律使用正斜杠/。因为classpath路径是归档内的统一约定,和操作系统无关。跨平台项目都应该用正斜杠。需要拼接路径时,可以用org.springframework.util.StringUtils或Path工具,但最简单有效的方法就是写死正斜杠。
4.5 需要在resources目录下创建临时文件
有一种场景是:模板或脚本必须以真实文件形式传给外部程序。此时先通过ClassPathResource读取输入流,再复制到系统的临时目录:
public File copyResourceToTempFile(String resourcePath, String suffix) throws IOException { ClassPathResource resource = new ClassPathResource(resourcePath); File tempFile = File.createTempFile("temp-", suffix); try (InputStream in = resource.getInputStream(); OutputStream out = new FileOutputStream(tempFile)) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } tempFile.deleteOnExit(); return tempFile; }这样就能同时保留“读取resources资源”的可靠性和“生成真实File”的便利性。
4.6 扫描resources目录下所有文件怎么处理
如果明确知道文件名,直接用ClassPathResource就够了。但如果你需要动态遍历某个目录下的所有资源,比如扫描resources/scripts下的所有Shell脚本,File在jar包内不可用,只能采用classpath扫描方式。一个简单做法是用PathMatchingResourcePatternResolver:
Resource[] resources = new PathMatchingResourcePatternResolver() .getResources("classpath:scripts/*.sh"); for (Resource resource : resources) { try (InputStream is = resource.getInputStream()) { // 处理每个文件 } }这个方案在开发环境会扫描真实目录,在jar包环境会扫描jar:file:路径下的归档条目,getResources会返回匹配的所有资源。注意通配符*匹配的是文件名,**可以匹配多级目录,例如classpath:scripts/**/*.sh。实际生产环境中如果控制类路径下面的脚本数量不多,用它扫描性能也还过得去。
5. 不同读取方式的对比与选型建议
5.1 完整对比表
我把常见方法整理成一张表,方便你对照决策。
| 方法 | 开发环境 | 打jar包后 | 适合场景 | 风险点 |
|---|---|---|---|---|
new File("src/main/resources/xxx") | 可能成功 | 必失败 | 基本不适合 | 路径随运行目录变化 |
ClassPathResource.getInputStream() | 成功 | 成功 | 读取单个文本/二进制资源 | 路径不带前缀/ |
ResourceLoader.getResource("classpath:xxx") | 成功 | 成功 | Spring容器中读取资源 | 注意前缀 |
ClassLoader.getResourceAsStream() | 成功 | 成功 | 非Spring环境或工具类 | 参数不能以/开头 |
Class.getResourceAsStream("/xxx") | 成功 | 成功 | 简单读取classpath根资源 | 是否带/含义不同 |
PathMatchingResourcePatternResolver | 成功 | 成功 | 批量扫描多个资源 | 通配符过多可能扫描慢 |
ApplicationContext.getResource() | 成功 | 成功 | 推荐在Spring Bean中使用 | 依赖Spring容器 |
可以看出,Spring Boot项目中最稳的组合是ClassPathResource配合getInputStream(),或者注入ResourceLoader配合classpath:前缀。
5.2 我的个人习惯
我自己的代码里,如果是Spring Bean,一般直接注入ResourceLoader,然后使用classpath:前缀。如果是工具类、静态方法,则用ClassPathResource。少数需要扫描多个资源的场景,才会动用PathMatchingResourcePatternResolver。这个组合几乎没有遇到过打jar包后不能读取的情况。
需要特别说明的是,有一种网络上的说法是“读取resources下的文件应该用ResourcePatternResolver获取所有文件路径再逐个getFile()”,这种思路在Spring Boot打成fat jar后,会因为getFile()失败而翻车。只要你是“读取内容”,不要用getFile();只有你真的需要外部程序访问真实临时文件时,才考虑先复制到java.io.tmpdir,再用File传递。
6. 实操心得:那些踩过坑之后我才明白的事
关于读取resources目录,我最大的体会是:不要把你的程序“运行目录”和“classpath路径”搞混。很多新手在IDE里调试时,工作目录恰好是项目根目录,所以new File("src/main/resources/xxx")能正常读;一旦部署到服务器,工作目录变成启动脚本所在目录,这个相对路径自然就失效了。与其去猜运行目录,不如直接使用classpath资源隔离差异。
另一个很实用的经验是,在写路径常量时,尽量把路径放到配置中心或常量类里。因为一旦resources目录结构调整,所有硬编码路径都要一起改。可以用一个ResourcePath常量类统一管理:
public final class ResourcePaths { public static final String APP_CONFIG = "config/app-config.json"; public static final String EMAIL_TEMPLATE = "templates/email.html"; private ResourcePaths() { } }调用时new ClassPathResource(ResourcePaths.APP_CONFIG),这样以后路径变更时,整个项目只需要改一个类。
关于测试,还有一点想提醒:写单元测试时,如果你依赖resources中的文件,测试用例读取到的也是target/test-classes下的资源,而不是src/test/resources外的src/main/resources。换句话说,主代码和测试代码的资源路径起点都是各自的classpath根,这是很容易忽略的规则。如果你不确定某个资源是否被打进去了,直接看target/classes或target/test-classes目录,那才是运行时真正能访问到的资源位置。
有人问过:“能不能直接在application.yml里引入resources下的文件作为配置?”当然可以,Spring Boot本身就会加载classpath:/application.yml,内部配置引用时用spring.config.import或spring.profiles.include都能指向classpath文件。但是业务代码里动态读取json、模板,还是要走上面讲的资源读取方式。
最后再分享一个排查问题的技巧:当你搞不清当前classpath下到底有哪些资源时,可以用JVM参数启动程序,把classpath打印出来:
java -jar demo.jar --debug或者在代码里临时加点日志:
System.out.println(System.getProperty("java.class.path")); System.out.println(getClass().getClassLoader().getResource(""));通过观察打印出的路径,你就能看到实际的classpath根在哪里,从而判断自己写的相对路径是否匹配。这个方法在复杂部署环境里特别管用,比自己凭猜测改路径要高效得多。
我在实际项目中,还遇到过一个比较冷门的问题:利用ClassPathResource读取文件后,如果资源文件在每次构建时间戳不断变化,IDE有时不会把最新版本复制到target/classes,导致运行时不刷新。解决方式是在Maven里配置resources插件的force参数,或者直接执行mvn clean package强制全量复制。这个经验虽然简单,但能省很多排查时间。