☰
SpringBoot读取resources文件:从ClassPathResource到jar包部署避坑指南
2026/10/13 2:14:52 网站建设 项目流程

在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 选型小结

不同方式各有适用场景,我平时做技术评估时按下面这个优先级看:

  1. 能用Spring就用ClassPathResource,最贴近SpringBoot体系,API简单。
  2. 做不到Spring注入的老代码,用ClassLoader.getResourceAsStream()。
  3. 业务层统一用流读取,千万别为“拿File对象”硬撑。
  4. 只有确确实实需要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”,再怀疑代码逻辑,千万别倒过来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询