在SpringBoot里读取resources目录下的文件,是我见过翻车率最高的需求之一。代码写得没有问题,本地跑也一切正常,结果一打成jar部署就报FileNotFoundException;或者是getResource()返回null;再或者文件读到了但中文全乱了。这类问题几乎每个月都能在技术群里看到几次。这篇就专门把这个点拆开讲:为什么一个简单的读取操作会衍生出这么多坑,以及在不同环境、不同场景下到底应该怎么选。文章适合刚用SpringBoot写接口的新手,也适合已经遇到jar包部署问题、想弄明白根因的中级开发者。我会把所有常用方式都写一遍,重点解释为什么有的写法开发环境能用、生产环境却不行,最后给出我自己线上项目里一直用的最稳方案。
1. 核心思路与方案全景:先搞清文件到底在哪个“目录”
1.1 开发环境与打包环境,是两种完全不同的世界
很多人写读取文件的第一步就是new File("src/main/resources/xxx.txt"),或者new File("config/template.html")。开发环境里这样确实能读到,因为工作目录通常是模块根目录,src/main/resources真实存在于磁盘上。可一旦执行mvn package把项目打成jar,代码运行在没有src/main/resources这个物理目录的环境里,文件会被打包进jar内部的classes或BOOT-INF/classes路径下,这时候相对路径立刻失效。
SpringBoot被构建成可执行jar时,内部结构并不是普通zip那样平铺的,而是像BOOT-INF/classes/、BOOT-INF/lib/这种带嵌套层级的布局。哪怕你侥幸用new File("target/classes/xxx.txt")在当前目录试对了,换台机器、换个工作目录启动,路径又对不上了。根本原因是:你写的代码假设了文件系统结构,而Java应用最终运行的形态可能根本没有这个结构。
1.2 为什么不能说“resources目录下”就是磁盘目录
在编译和打包的过程中,src/main/resources里的内容会被Maven或Gradle复制到target/classes目录,也就是classpath的根目录。运行时,resources的文件和编译后的.class文件处于同一个classpath位置,你可以把classpath粗略理解成“Java运行时查找类文件和资源文件的搜索路径”。
所以,读取resources下的文件,本质上不是一个“文件系统路径”问题,而是一个“classpath资源定位”问题。你要找的不是磁盘上的/abc/def/file.txt,而是一个名为xxx.txt的classpath资源。理解这一点,后面所有写法选型的逻辑就顺了:凡是直接操作文件路径的写法都不稳,凡是基于classpath的写法都稳。
1.3 常用获取方式全景对比
我把实际开发中见过的所有获取方式罗列出来,先给一个总体对比表,后面逐项细说。
| 获取方式 | 开发环境 | 打包成jar后 | 适用场景 |
|---|---|---|---|
new File("src/main/resources/...") | 可能成功 | 必失败 | 不推荐 |
Class.getResource("...") | 成功 | 大部分成功 | 相对包路径定位 |
ClassLoader.getResource("...") | 成功 | 成功 | classpath根路径定位 |
getResourceAsStream()系列 | 成功 | 成功 | 读取内容,推荐 |
ClassPathResource | 成功 | 成功 | Spring推荐,最省心 |
ResourceLoader | 成功 | 成功 | 依赖注入风格 |
ResourceUtils.getFile() | 成功 | 经常失败 | 只在信得过的环境用 |
表格里最迷惑的一项就是ResourceUtils.getFile():它在开发环境一切正常,但遇到spring-boot-maven-plugin打成的可执行jar,十有八九会抛FileNotFoundException。原因后面细讲,这里先把结论写出来:能用流读取就用流读取,不要跟“必须拿到File对象”这个执念死磕。
2. 四种常用获取方式逐个拆解
2.1 ClassPathResource:最省心的Spring封装
如果你已经在用SpringBoot,org.springframework.core.io.ClassPathResource是首选。它是Spring对classpath资源的封装,构造时直接给classpath根路径下的相对路径,不需要关心当前类是哪个包。
import org.springframework.core.io.ClassPathResource; ClassPathResource resource = new ClassPathResource("config/app.properties"); if (resource.exists()) { try (InputStream in = resource.getInputStream()) { Properties props = new Properties(); props.load(in); System.out.println(props.getProperty("app.name")); } }注意两点。第一,new ClassPathResource("config/app.properties")默认相对于classpath根目录查找,不要在前面加/,加了反而会改变查找语义。第二,resource.getFile()在开发环境能拿到File对象,在打包后的jar里却会抛异常,因为它尝试把jar:file:/...BOOT-INF/classes/config/app.properties解析成磁盘文件。所以我一律只用getInputStream(),从InputStream里读取数据,从源头规避这个坑。
实证经验:ClassPathResource除了处理普通文件和jar包,还能处理war、ear等不同形态的应用包,底层根据URL协议自动选择路径解析方式。这比手动判断环境要可靠得多,值得把它当作默认选项。
2.2 ResourceLoader:用Spring注入的方式拿Resource对象
在Spring生态里,你不需要自己new ClassPathResource,直接注入ResourceLoader接口即可。因为Spring容器内部会把ResourceLoader当成一种通用的资源加载器,支持classpath:、file:、url:等多种前缀。
@Service public class SomeService { private final ResourceLoader resourceLoader; public SomeService(ResourceLoader resourceLoader) { this.resourceLoader = resourceLoader; } public String loadConfig() throws IOException { Resource resource = resourceLoader.getResource("classpath:config/app.properties"); try (InputStream in = resource.getInputStream()) { // 读取内容 return new String(in.readAllBytes(), StandardCharsets.UTF_8); } } }ResourceLoader的好处是:调用方不关心资源到底来自classpath还是文件系统,完全由前缀和Resource的实现类决定。如果哪天你想把某些配置改到外部文件目录,只需把前缀从classpath:改成file:/etc/app/,业务代码不用动。这也是它在工程化项目里受欢迎的原因。
2.3 Class.getResource() 与 ClassLoader.getResource():原生写法,细节容易翻车
不用Spring的纯Java写法,最常用的是:
// ClassLoader方式:相对classpath根 InputStream in1 = Thread.currentThread().getContextClassLoader() .getResourceAsStream("config/app.properties"); // Class方式:相对当前类所在包 InputStream in2 = SomeClass.class.getResourceAsStream("/config/app.properties"); InputStream in3 = SomeClass.class.getResourceAsStream("config/app.properties");这两者最大的区别是相对路径的基准。类.class.getResourceAsStream()是以当前类所在的包为基准的,如果传入/config/app.properties,则去掉前导斜杠,回到classpath根目录查找;如果传入config/app.properties,则相对于当前包路径查找。而ClassLoader.getResourceAsStream()永远以classpath根目录为基准,不能以/开头。
实际项目中我见过太多因此产生的bug:类在com.example.service包下,用SomeClass.class.getResourceAsStream("config/app.properties")去读,结果一路在com/example/service/config/下找,找不到就返回null。排查了半天,最后发现是多了一个斜杠或少了一个斜杠的问题。
建议:在不涉及Spring的情况下,优先使用ClassLoader.getResourceAsStream("config/app.properties"),路径语义更直白,且天然支持jar包。
2.4 ResourceUtils.getFile():曾经的主流,但别过度迷信
老项目中经常看到这种写法:
File file = ResourceUtils.getFile("classpath:config/app.properties");在普通的Spring项目或独立可执行jar不是以SpringBoot插件方式打包时,它通常能正常工作。但在SpringBoot标准可执行jar里,ResourceUtils会尝试把classpath:协议解析成文件系统URL,而对像jar:file:/path/app.jar!/BOOT-INF/classes/...这种嵌套路径,解析很容易失败,抛出“class path resource cannot be resolved to absolute file path”之类的错误,因为jarpath根本不是一个普通目录。
现在我的建议很明确:除非你知道自己在做什么,否则不要用ResourceUtils.getFile()读resources。如果确实需要File对象(比如要交给某些只接受File的第三方库),要另想办法,下面第3节会给替代方案。
2.5 选型小结
不同方式各有适用场景,我平时做技术评估时按下面这个优先级看:
- 能用Spring就用
ClassPathResource,最贴近SpringBoot体系,API简单。 - 做不到Spring注入的老代码,用
ClassLoader.getResourceAsStream()。 - 业务层统一用流读取,千万别为“拿File对象”硬撑。
- 只有确确实实需要
java.io.File时才考虑临时文件方案。
3. 打包成jar之后的关键教训
3.1 jar内部只有“条目”,没有“目录”
先纠正一个根深蒂固的错误认知:很多人以为jar是一个目录树,存在BOOT-INF/classes下就是一个磁盘目录。不是。jar本质上是一个zip压缩包,内部每个文件都是一个zip entry,所谓的BOOT-INF/classes/只是entry名称里包含的斜杠路径,操作系统并不把它当成真实目录。
因此,对jar内资源执行File.isDirectory()永远返回false;执行File.listFiles()会得到空数组或者直接抛异常。如果你有一个批量加载resources下所有模板文件的需求,千万不要试图:
File dir = new File(classpathResource.getFile().getPath()); File[] files = dir.listFiles(); // jar内必踩坑正确做法是用PathMatchingResourcePatternResolver这类能扫描classpath的组件,或者干脆写死资源清单,逐个流式读取。Spring自身就提供了基于classpath模式的通配符加载能力,但没有Spring的场景下就老老实实枚举资源名称。
3.2 能读流就绝对不要碰文件系统
在可执行jar中,安全的读取方式是直接拿InputStream。无论开发还是生产,getResourceAsStream和ClassPathResource.getInputStream()都是走jar的zip读取逻辑,不依赖真实磁盘路径。凡是能在“读流”层面解决的问题,都不要升级到“文件系统”层面。
这句话值得反复强调,因为很多第三方工具库只接受File参数,比如某些词库加载器、证书加载器、模板引擎。遇到这种情况,很多人的第一反应是resource.getFile(),然后被jar环境打脸。正确的心智模型是:你的目标是拿到文件内容,File只是内容的载体之一,不是唯一载体。
3.3 迫不得已需要File对象时的临时文件方案
如果某个库确实只支持File,解决办法是先把classpath资源复制到系统的临时目录,生成一个真实的临时文件,再把这个File传给业务方。
ClassPathResource resource = new ClassPathResource("data/keyword.txt"); try (InputStream in = resource.getInputStream()) { Path temp = Files.createTempFile("app-", ".tmp"); Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); temp.toFile().deleteOnExit(); // 把这个temp.toFile()传给只支持File的库 }deleteOnExit()保证JVM退出时清理文件。需要注意:如果是高并发请求,每次都创建临时文件会带来IO开销,建议把复制结果缓存到内存或重复复用一个固定文件。
3.4 使用PathMatchingResourcePatternResolver批量扫描classpath资源
SpringBoot中想要获取resources下某个通配符范围内的所有文件,比如读取classpath*:templates/*.html,用PathMatchingResourcePatternResolver是最顺手的:
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); Resource[] resources = resolver.getResources("classpath*:templates/*.html"); for (Resource resource : resources) { try (InputStream in = resource.getInputStream()) { // 处理每个模板 } }classpath*:前缀会扫描所有jar和classpath路径,而classpath:只扫描第一个匹配项。在resources数量不明确、需要动态加载的场景下,这个方式能省很多事。但要注意:如果打成的是SpringBoot fat jar,Maven并没有把resources目录作为独立jar放到BOOT-INF/lib,classpath*:依然可以工作,因为SpringBoot的LaunchedURLClassLoader接管了资源查找,处理了BOOT-INF/classes的特殊情况。
4. 实战案例:读取配置文件与模板,改造一个老项目的完整过程
4.1 案例背景:从相对路径到处炸,到全部切换classpath流式读取
我之前接手过一个旧版管理后台,里面有一堆告警规则模板,运行时要读src/main/resources/templates/rule.ftl,代码里写死了new File("src/main/resources/templates/rule.ftl")。开发环境跑得很欢,一打成jar部署就报模板找不到。线上的排查方式和本地完全不同,一直找不到模板文件在哪,最后用jar tf app.jar | grep rule.ftl才发现模板在BOOT-INF/classes/templates/rule.ftl。
改造思路很简单:所有读取resources的代码统一改成ClassPathResource + getInputStream(),并删除所有对src路径的硬编码。
4.2 改造一:把Properties文件读取改成ClassPathResource
先看最普通的properties配置读取:
// 改造前 // File file = new File("src/main/resources/config/alert.properties"); // FileInputStream fis = new FileInputStream(file); // 改造后 ClassPathResource resource = new ClassPathResource("config/alert.properties"); try (InputStream in = resource.getInputStream(); InputStreamReader reader = new InputStreamReader(in, StandardCharsets.UTF_8)) { Properties props = new Properties(); props.load(reader); }细节:Properties.load(InputStream)默认按ISO-8859-1解码,非英文内容会乱码。我习惯先用InputStreamReader指定UTF-8,再交给Properties.load(Reader)。这一步是很多中文乱码问题的根源。
4.3 改造二:动态加载模板并渲染
模板引擎Freemarker在配置模板路径时也经常踩同样的坑。不推荐配置成src/main/resources/templates这种文件系统路径,应该直接配classpath:
spring: freemarker: template-loader-path: classpath:/templates/在代码里,如果模板不多,可以直接用ClassPathResource拿到模板内容再手动做简单占位符替换:
ClassPathResource resource = new ClassPathResource("templates/rule.ftl"); String template; try (InputStream in = resource.getInputStream()) { template = new String(in.readAllBytes(), StandardCharsets.UTF_8); } String content = template.replace("{{host}}", host) .replace("{{threshold}}", threshold);需要注意的是,InputStream.readAllBytes()是JDK9才有的。如果你还在用JDK8,就用IOUtils.toString(in, StandardCharsets.UTF_8)(commons-io)或者自己循环读字节。
4.4 改造三:通用工具方法,一次封装到处用
我后来在项目里沉淀了一个工具方法,所有读取resources的场景都走它:
public static String readClasspathText(String classpathFile) { ClassPathResource resource = new ClassPathResource(classpathFile); if (!resource.exists()) { throw new IllegalArgumentException("资源不存在: " + classpathFile); } try (InputStream in = resource.getInputStream()) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException("读取资源失败: " + classpathFile, e); } }需要读字节数组时,再加一个返回byte[]的重载。这套工具的收益很明显:调用方从此不需要关心资源在磁盘还是jar内,只关心资源名;排查问题时也只需要问“资源名写对了吗”。
4.5 改造后的验证
改造后,我在本地及打包部署环境分别验证:本地通过IDEA直接运行,读取正常;使用mvn package后的jar,部署在不同工作目录下,读取也正常。这一步验证很关键,因为打包后的环境和开发环境是两个世界,切不可只验证一种环境就收工。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 症状 | 常见原因 | 解决方向 |
|---|---|---|
getResource()返回null | 路径写错、相对基准理解错了 | 确认是否加了/或漏了/ |
jar包部署后FileNotFoundException | 用了文件系统路径或getFile() | 改为getInputStream()或临时文件方案 |
| 中文乱码 | 读取编码与文件保存编码不一致 | 统一UTF-8,Properties用Reader加载 |
| 打包后资源未更新 | IDEA的target/classes缓存了旧文件 | 执行mvn clean再打包 |
classpath*:找不到jar内资源 | 双亲委派类加载器差异 | 优先用Spring的PathMatchingResourcePatternResolver |
| 多模块项目在A模块读不到B模块的resources | 资源不在当前classpath | 确认依赖关系,资源所在模块要被打进可执行jar |
| 文件名含空格或中文 | 直接拼成URL解析失败 | classpath路径不需要encode,直接用字符串 |
5.2 排查技巧:先站在classpath视角看问题
遇到“读取不到”的问题,第一步不是改代码,而是确认资源到底在不在classpath里。开发环境可以打印:
System.out.println(System.getProperty("java.class.path"));或者直接看target/classes下有没有对应文件。打包环境则用:
jar tf app.jar | grep 资源名如果jar里根本没有这个资源,那问题大概率出在Maven构建配置上,比如resources目录没被包含、被filter排除等。这时候改代码也没用,先修构建配置。
还有一个我从实战中学到的小技巧:把完整的classpath打印出来,确认是否存在多模块导致的资源不合并问题。SpringBoot可执行jar的BOOT-INF/lib里的依赖,每个jar都有自己的classpath,跨模块引用字体包、词库包时必须确认依赖坐标有没有继承下来。
5.3 类加载器差异:别在Web容器里把所有锅都甩给路径
在传统外部Tomcat部署war包时,资源加载还会受到类加载器隔离的影响。Web应用的Thread.currentThread().getContextClassLoader()和SomeClass.class.getClassLoader()可能不是同一个,导致有些写法在单元测试里好使,部署后却找不到资源。
经验上是这样:如果你明确是classpath根路径下的资源,优先用Thread.currentThread().getContextClassLoader().getResourceAsStream();如果你明确资源与某个类在同一个模块,优先用该类的ClassLoader.getResourceAsStream()。但SpringBoot内嵌容器场景下,这类差异不多见,用ClassPathResource基本能规避大部分问题。
5.4 我的最终推荐
综合这么多年的经验,我的最终选型很简单:
- 无脑优先
ClassPathResource或ResourceLoader,用getInputStream()读取。 - 没有Spring的纯Java场景,用
ClassLoader.getResourceAsStream()。 - 绝对不写
new File("src/main/resources/...")。 - 除非必须File对象,否则绝不转文件系统路径。
这套规则在我维护的多个项目中稳定运行了很长时间,几乎没有再因为resources读取出过线上事故。
最后再分享一个压箱底的小经验:IDEA里偶发的“能看到resources文件但运行时读不到”,多是Maven没有重新resources复制导致的,跑一遍mvn clean package基本能解决。资源读取问题的排查顺序,永远是先确认“资源在不在classpath”,再怀疑代码逻辑,千万别倒过来。