先说我自己的经历。早些年做Spring Boot项目,本地IDE跑接口一切正常,一旦打成jar包部署到服务器,很多文件读取就奇迹般地失效了。排查了半天,发现不是路径敲错,而是对resource目录的理解有偏差。后来我把这个系列问题系统整理了一遍,发现市面上的说法很零散,真正有体系、能直接抄的并不多,所以才有了这篇东西:Spring Boot项目中读取resource目录下的文件,我整理了六种方法,每种都配了可运行代码,也说明了各自的适用边界。
这篇内容适合谁看?一种是刚开始用Spring Boot、被FileNotFoundException折磨的新手;另一种是已经能跑通项目,但在jar包部署、多模块构建、批量扫描资源这些场景下碰到过诡异问题的同学。我会把"为什么有的方法在本地好使、打包就挂"这件事讲透,而不是只丢一堆API让你背。
1. 先说清楚:resource目录下的文件怎么就被"读"到了
1.1 开发环境下看到的路径,和部署后完全是两回事
很多人第一次踩坑,都是从"本地能读,部署就挂"开始的。要理解这个现象,得先搞明白resource目录在构建过程中发生了什么。
Spring Boot标准的Maven工程里,src/main/resources目录放的是配置文件、模板、静态资源这类非Java文件。执行mvn package编译时,Maven的resources插件会把整个目录原样拷贝到target/classes下面。也就是说,src/main/resources/data/config.json编译后变成了target/classes/data/config.json。
target/classes是什么?它是classpath的一份子。JVM启动时会在classpath指定的路径里寻找.class文件,同时也找普通资源文件。所以resource目录下的文件,对齐到JVM层面,本质上是"类路径资源",不是"某个磁盘目录里的普通文件"。这个认知非常关键。
开发模式下,IDE把target/classes直接当作classpath,你读data/config.json就等于读磁盘上那个物理文件,一切正常。部署时Spring Boot打成fat jar,BOOT-INF/classes目录相当于原来的target/classes,但它被封装在jar包内部,物理路径变成了类似jar:file:/path/to/app.jar!/BOOT-INF/classes!/data/config.json这样的结构。这时候如果你还按"文件系统路径"的思路去定位,当然找不到。
1.2 两种读取语义:类路径资源与文件系统路径
正是因为上面这个差异,接下来所有方法都绕不开一个选择:你到底想用哪种语义去读?
- 类路径资源语义:通过ClassLoader或Spring的Resource抽象,按classpath中的逻辑路径定位资源,不关心它在磁盘上的真实位置。这类方式在jar包内外都能工作,是推荐的默认选择。
- 文件系统路径语义:通过
File、Paths.get()这类API,按操作系统路径定位文件。这类方式只在目录部署(比如IDE运行、解压后的war包)下才稳定。
很多看起来"能跑"的代码,其实混用了两种语义,本地碰巧能用,换个环境就炸。后面讲到第六种方法时会看到典型的反面案例。
2. 六种读取方式逐个拆解(附可直接抄的代码)
2.1 方式一:ClassPathResource,Spring最基础的资源封装
Spring框架从很早就提供了org.springframework.core.io.ClassPathResource,它是Spring Resource抽象体系中专门处理类路径资源的实现。核心用法非常简单:
import org.springframework.core.io.ClassPathResource; import org.springframework.util.StreamUtils; import java.nio.charset.StandardCharsets; public String readConfig() throws IOException { ClassPathResource resource = new ClassPathResource("data/config.json"); try (InputStream is = resource.getInputStream()) { return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } }ClassPathResource的构造函数接收一个相对于classpath根目录的路径,data/config.json对应src/main/resources/data/config.json。注意不要以斜杠开头,按我多年的使用经验,写了/data/config.json在某些版本下也能找到,但这属于未明确约定的行为,不够踏实。真正干活的入口是getInputStream(),它内部通过ClassLoader去定位并打开流,不依赖文件系统路径,所以fat jar里照样能读。
这个类还提供了getFile()、getURL()、exists()等方法,但这里我只建议把getInputStream()当主力,原因在后面原理部分展开。
2.2 方式二:ResourceLoader注入,把资源查找交给容器
Spring Boot的ApplicationContext本身就实现了ResourceLoader接口,所以你在任何Bean里都可以直接注入ResourceLoader,用它来加载资源:
@Service public class ConfigService { private final ResourceLoader resourceLoader; public ConfigService(ResourceLoader resourceLoader) { this.resourceLoader = resourceLoader; } public String readTemplate() throws IOException { Resource resource = resourceLoader.getResource("classpath:templates/mail-template.html"); try (InputStream is = resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } }getResource()支持多种前缀:classpath:、file:、url:,以及不带前缀的默认解析。在Spring Boot的ApplicationContext场景下,不带前缀默认按classpath:处理,但为了可读性和避免歧义,我强烈建议显式写classpath:。这个方式的优势是解耦:你的代码只面向Resource接口,具体底层是类路径、文件系统还是远程URL,由前缀决定。需要读取外部化配置时,把"classpath:xxx"换成"file:/etc/app/xxx"就行,业务代码一行不用改。
顺带提一句,直接注入ApplicationContext也能达到同样效果,因为ApplicationContext继承了ResourceLoader。但ResourceLoader的语义更窄、更清晰,测试时也更容易替换。
2.3 方式三:Class.getResourceAsStream,最稳妥的原生方案
不依赖Spring的话,Java标准库本身就有读取类路径资源的能力,最常用的就是Class.getResourceAsStream():
public String readSql() throws IOException { try (InputStream is = getClass().getResourceAsStream("/db/init.sql")) { if (is == null) { throw new FileNotFoundException("resource not found: /db/init.sql"); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } }这个方法的关键在于斜杠规则:以/开头,表示从classpath根目录找;不以/开头,表示相对于当前类所在的包目录找。比如com.example.demo.service.DemoService类里写getClass().getResourceAsStream("data.json"),实际找的是com/example/demo/service/data.json。这是个非常容易踩的隐性差异,建议统一用绝对classpath形式(斜杠开头),可读性最好。
相比前两种Spring方式,这个方法的优点是零框架依赖,任意一个Java类拿来就能用。缺点是只能拿到InputStream,拿不到文件名字、修改时间这类元数据。如果你的需求就是"把里面的内容读出来当字符串用",它完全够用。
2.4 方式四:ClassLoader.getResourceAsStream,斜杠规则要格外小心
ClassLoader也有一个getResourceAsStream方法,用法和Class.getResourceAsStream类似,但斜杠规则刚好相反:
public String readConfig() throws IOException { ClassLoader cl = Thread.currentThread().getContextClassLoader(); try (InputStream is = cl.getResourceAsStream("data/config.json")) { if (is == null) { throw new FileNotFoundException("resource not found: data/config.json"); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } }ClassLoader.getResourceAsStream()永远相对于classpath根目录,所以路径不能以斜杠开头。如果你写cl.getResourceAsStream("/data/config.json"),大概率返回null,因为ClassLoader会直接把你给的路径拿去classpath里匹配,开头的斜杠变成了一个不存在的目录名。这个细节我见过的踩坑案例比想象中多得多。
还有一点值得注意:DemoService.class.getClassLoader()和Thread.currentThread().getContextClassLoader()在绝大多数Spring Boot场景下指向同一个类加载器,但在某些中间件、OSGi、自定义ClassLoader环境里可能有差异。做基础设施类代码时,优先用线程上下文类加载器(TCCL),它在框架环境中更可靠。当然,如果getResourceAsStream返回null,要主动抛出异常而不是让空指针在后面炸,否则排查问题时你看到的报错信息会非常误导。
2.5 方式五:PathMatchingResourcePatternResolver,一次拿一坨
前四种方法只能定位单个文件,想读取目录下所有匹配的文件,或者扫描多个jar包里的同名资源,就需要PathMatchingResourcePatternResolver:
import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.core.io.Resource; public List<String> readAllSqlScripts() throws IOException { ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); Resource[] resources = resolver.getResources("classpath*:db/migration/*.sql"); List<String> contents = new ArrayList<>(); for (Resource resource : resources) { try (InputStream is = resource.getInputStream()) { contents.add(new String(is.readAllBytes(), StandardCharsets.UTF_8)); } } return contents; }注意这里的前缀是classpath*:,不是classpath:。多一个星号含义完全不同:classpath:只从第一个匹配到的classpath路径里找,classpath*:会扫描全部classpath路径,包括依赖jar包里的资源。比如你的工程和某个公共依赖包里都有data/common.json,用classpath:永远只拿第一个,用classpath*:两个都能拿到。
getResources支持Ant风格的通配符:*.sql匹配文件名,db/**/*.sql匹配子目录,classpath*:加/**可以做递归扫描。批量读取SQL迁移脚本、模板文件列表这类需求,用这个方式非常顺手。
2.6 方式六:通过URL拿File,这个方式我劝你慎用
标题里说六种方法,但我要把第六种定位成"反面教材式的方法",因为它在网上流传很广,却最容易在实际部署时坑人。典型写法长这样:
// 本地IDE跑得好好的,打成jar包后大概率抛异常 ClassPathResource resource = new ClassPathResource("data/config.json"); File file = resource.getFile(); // 或者这种写法 URL url = getClass().getResource("/data/config.json"); File file = new File(url.toURI());这两种方式的本质,是先把资源定位到,再强行转换成File对象。开发环境里classpath是目录,resource.getFile()能直接拿到物理文件,一切正常。jar包环境下,资源被封在jar包内部,根本没有对应的操作系统文件路径,getFile()会抛FileNotFoundException,url.toURI()得到的是jar:file:...这种无法转成File的协议,一样会挂。
如果你确实需要把resource里的文件转成File传给某个第三方SDK(比如某些Excel处理库只接收File参数),正确的姿势是先把内容读成InputStream/字节数组,再写入临时目录:
ClassPathResource resource = new ClassPathResource("data/template.xlsx"); Path tempFile = Files.createTempFile("template", ".xlsx"); try (InputStream is = resource.getInputStream()) { Files.copy(is, tempFile, StandardCharsets.UTF_8, StandardCopyOption.REPLACE_EXISTING); } File file = tempFile.toFile(); // 用完后记得删除临时文件这是我在实际项目中验证过最稳的做法。程序退出前记得清理临时文件,不然服务器上会积累一堆垃圾。
3. 原理层面:为什么有的方式在jar包里会失效
3.1 Spring Resource抽象帮我们挡掉了什么
先看Spring的Resource接口,它的核心能力是:把"一个可读取的资源"抽象成统一的InputStream获取入口。调用方不用管底层是file:协议、classpath:协议还是http:协议,只要调用getInputStream()就能拿到流。
ClassPathResource内部拿到资源时,本质上是调用了ClassLoader.getResource(),得到的是一个URL。在开发环境里,这个URL可能是file:/path/to/target/classes/data/config.json;在jar包环境里,它可能是jar:file:/path/to/app.jar!/BOOT-INF/classes!/data/config.json。URL协议不同,后续能做的事就完全不同。URL.openStream()对file:和jar:协议都能正常打开流,所以getInputStream()在两种场景下都好使;但URL.getFile()或Resource.getFile()只在file:协议下才有效,jar:协议下拿到的路径根本不对,强行转换自然失败。
这就是"流式读取在jar包内外都通用,转File却只在目录部署时可用"的根本原因。理解了这一层,你就能明白为什么我在前五种方法的代码里反复强调用InputStream而不是File。
3.2 ClassLoader的资源查找顺序
ClassLoader查找资源的逻辑和加载类基本一致。以AppClassLoader为例,它会遍历classpath中的每个路径:对于目录,直接拼接路径去找文件;对于jar包,通过JarFile内部的索引去匹配条目。返回的是第一个命中的结果。如果classpath里有多个同名资源,靠前优先。
Spring Boot的可执行jar(fat jar)有点特殊,它自己实现的LaunchedURLClassLoader会把BOOT-INF/classes/和BOOT-INF/lib/下的所有jar包都纳入查找范围。这就是为什么Spring Boot jar包里的资源,用ClassLoader.getResourceAsStream照样能读到。但要注意,你看到的路径是jar:file:...!/BOOT-INF/classes!/data/config.json,这已经和普通的file:路径完全不同了,任何基于File的假设都会失效。
3.3 环境差异:IDE运行、java -jar、外置容器
同一种代码在不同运行环境下表现不同,这点值得单独列出来:
| 运行方式 | classpath形态 | 能否用 getFile() | 能否用 InputStream |
|---|---|---|---|
| IDE里直接运行(目录classpath) | 多个物理目录 | 可以 | 可以 |
| java -jar(fat jar) | BOOT-INF/classes + 依赖jar | 不行 | 可以 |
| 外置Tomcat部署war(解压目录) | WEB-INF/classes + WEB-INF/lib | 视容器和解压策略而定(多数可以) | 可以 |
这张表我建议保存下来。很多"为什么本地可以、测试环境挂了"的诡异问题,追根溯源都逃不出这几种环境差异。外置容器那一行尤其阴险:它在默认配置下通常能用getFile(),但如果容器配置了禁止解压war包,或者你的war包被放在某些特殊位置,行为又不一样了。依赖一个"多数情况下可以、偶尔不行"的能力,本身就是给线上埋雷。
4. 选型建议:什么时候用哪种方式
4.1 六种方式横向对比表
这六种方式看着多,其实底层无非是两条路线:Spring的Resource抽象,和Java原生的ClassLoader定位。整理成表格会更清楚:
| 方式 | 依赖Spring | 是否流式读取 | jar包内可用 | 支持批量 | 适合场景 |
|---|---|---|---|---|---|
| ClassPathResource | 是 | 是 | 是 | 否 | 读取单个配置文件/模板 |
| ResourceLoader注入 | 是 | 是 | 是 | 否 | 需要统一管理前缀、便于替换路径来源 |
| Class.getResourceAsStream | 否 | 是 | 是 | 否 | 非Spring环境或工具类 |
| ClassLoader.getResourceAsStream | 否 | 是 | 是 | 否 | 框架底层、自定义ClassLoader场景 |
| PathMatchingResourcePatternResolver | 是 | 是 | 是 | 是 | 扫描目录、批量文件 |
| URL转File | 不一定 | 否 | 否 | 否 | 只适合开发调试,不建议上线使用 |
4.2 典型场景分析
场景一:读取单个配置文件
首选ClassPathResource,简单直接,代码量最少。如果希望这个路径可被外部配置覆盖,考虑ResourceLoader + classpath:/file:前缀切换。
场景二:读取邮件模板、Excel模板
模板文件通常不大,用ClassPathResource读成InputStream,再转成字符串或字节数组即可。需要把模板写成临时文件传给第三方SDK时,走2.6节里说的"复制到临时文件"方案,不要试图直接拿jar包内的File。
场景三:批量执行SQL脚本
用PathMatchingResourcePatternResolver配合classpath*:db/migration/*.sql,一次性拿全部脚本,按文件名排序后逐条执行。这里注意classpath*:才能跨jar包扫描。
场景四:工具类里读取资源(不依赖Spring)
用Class.getResourceAsStream或ClassLoader.getResourceAsStream。工具类通常不在Spring容器管辖范围内,尽量用纯Java API,少引入框架依赖。
5. 实战中的高频坑与排查思路
5.1 jar包内文件不能直接new File
这个问题我在前面反复强调过,但它值得单独拿出来再说一次,因为实际案例里翻车率太高了。有人把资源路径写成new File("data/config.json"),本地能跑就以为万事大吉,结果部署后直接抛异常。原因很简单:new File走的是文件系统路径,而data/config.json在jar包环境下根本不在当前工作目录里,它被封装在jar包内部,操作系统根本看不到。
判断自己是不是踩了这个坑,有一个最简单的辨别方法:看IDE里能不能找到那个路径。如果IDE的文件树里能看到data/config.json,那new File("data/config.json")可能正巧能用;如果它只是存在于target/classes里的编译产物,但工程根目录下根本没有这个目录,那这个写法就是纯碰运气。
5.2 中文文件名与URL编码问题
resource目录下的文件如果包含中文名(比如数据模板.xlsx),用Class.getResource拿到的URL可能是file:/.../%E6%95%B0%E6%8D%AE%E6%A8%A1%E6%9D%BF.xlsx这种URL编码形式。直接new File(url.toURI())在某些平台下能正确解码,但在另一些环境下会报找不到文件。
我吃过这个亏之后,规则很简单:URL转File之前,先确认URL协议,再用new File(url.toURI())并捕获URISyntaxException。更省心的做法是彻底绕开File,用InputStream读取,然后用URLDecoder.decode处理文件名(如果确实需要文件名的话)。另外,文件名里的空格也可能导致路径解析出错,用URI而非直接拼接字符串,可以避免大部分这类问题。
5.3 多模块工程下目标文件没被编译进classes
还有一种"怎么都读不到"的情况,跟代码无关:多模块Maven工程中,某个模块的src/main/resources下放了文件,但依赖它的模块里怎么读都是null。检查一下部署产物的实际内容:
jar tf app.jar | grep config.json如果jar包里根本没有这个文件,那就是构建层面的问题。常见原因是:源文件放在了src/main/java下(Maven默认不把.java目录里非.java的杂项文件当资源处理),或者那个模块的pom.xml里配置了<resources>覆盖默认规则。更隐蔽的是maven-resources-plugin的版本和编码配置不一致,导致文件复制时丢失。把文件放到标准src/main/resources,并用mvn clean package重新打包验证,能排除大部分构建问题。
5.4 排查资源路径问题的通用套路
最后分享一套我排查资源读取问题时的固定思路,基本能覆盖八成场景:
第一步,确认文件是否真的在classpath里。IDE里执行getClass().getClassLoader().getResource("data/config.json"),把打印出来的URL贴到浏览器地址栏试试能不能访问。这一步能区分是"路径不对"还是"文件没打包进去"。
第二步,确认当前运行环境的classpath形态。是在IDE里跑、java -jar还是外置容器?直接看启动命令或部署方式就能判断,然后对照3.3节的表格,排除掉"getFile()依赖"这类环境敏感写法。
第三步,统一改用InputStream读取。只要不是非要File对象不可,一律用getInputStream(),绝不直接转File。这一步能消除绝大多数jar包部署差异。
第四步,如果确认路径和环境都没问题,还是读不到,检查有没有多个同名资源在classpath里"截胡"。用getResources()(注意是复数)把所有匹配的URL打印出来,看看第一个命中的是不是你想要的那个。依赖库里同名文件覆盖项目内文件的情况,我遇到过不止一次。
第五步,加日志打印资源URL。别嫌麻烦,System.out.println(resource.getURL())一行代码,往往能让你少排查半小时。看到URL里的协议和路径结构,问题基本就水落石出了。
按照这个套路走一遍,resource目录文件读取的问题基本都能定位到根因。这套方法我用到现在,还没有遇到过解决不了的资源路径问题。