
用SpringBoot做后端的同学迟早会在某个深夜遇到同一个鬼故事在IDE里跑得好好的resource目录下的文件读得顺顺利利一旦用mvn package打成jar包扔到服务器上代码原地爆炸报一个FileNotFoundException而且报错位置距离你真正读文件的那行代码还很远排查一半天。这篇文章就把SpringBoot项目中读取resource目录下的文件这件事彻底讲透一共整理了六种方法每种我都会给出完整代码、运行表现和适用场景并且在最后讲清楚为什么有些方法在jar包环境下会挂、怎么样才踩不进这些坑。适合刚学SpringBoot的开发者也适合被这个问题坑过但没搞清楚原理的老手。1. 先搞清楚为什么resource目录里的文件打包前后读起来完全不一样1.1 一个能跑通的demo一个打崩的部署文件去哪了先说一个最基本的背景。SpringBoot项目的标准结构里src/main/resources目录下放的是配置文件、模板文件、SQL脚本、静态资源等。你在IDE里点启动时构建工具Maven或Gradle会把项目编译到target目录其中src/main/resources下的所有东西会被原样复制到target/classes下面。classes目录就是classpath的根目录。所以在IDE里运行时你的程序访问classpath下的文件实际上访问的是物理磁盘里的一个真实路径比如C:/projects/demo/target/classes/config/template.json这个阶段你随便玩new File、FileInputStream、各种方式都能读因为文件系统里确实存在这个文件。但部署时不一样。你把项目打成SpringBoot的fat jar可执行jar包后target/classes的内容会被压缩到一个jar包里面这个jar包的内部结构大概是这样demo.jar/ ├── BOOT-INF/ │ ├── classes/ │ │ ├── application.yml │ │ ├── config/template.json │ │ └── com/example/DemoApplication.class │ └── lib/ │ └── spring-boot-xxx.jar └── org/springframework/boot/loader/注意jar包本质上是一个zip压缩包操作系统里的普通文件系统并不认识jar包内部那条虚拟路径。你在代码里拿File去访问jar内部的某个文件路径时文件系统会说对不起根本没有这个东西。这就是为什么同样一段代码IDE里能跑服务器上就崩。问题不是出在文件不存在而是出在你以文件系统路径的方式去访问一个并不存在于文件系统里的文件。1.2 File和InputStream的根本区别路径只是一张地图很多刚入行的朋友会把读取文件简单地等同于拿到一个File对象然后读出内容。但在Java的语境里这两件事要分开看File代表的是文件系统中的一个路径入口它依赖操作系统对文件的组织方式比如Windows的盘符、Linux的绝对路径等。InputStream代表的是字节流它的来源可以是文件系统、网络连接、内存数组甚至是一个zip压缩包内部的虚拟条目。你把一张地图当成房子本身当然会撞墙。jar包内部的文件只有Inception式的虚拟路径操作系统不认识它File对象的底层API也不认它。但是InputStream可以认因为jar包里面的JarURLConnection等API知道怎么把虚拟条目读成字节流。这里也顺便引出六种方法里面最关键的一个判断标准哪种方式是纯靠classpath机制去定位资源然后拿流哪种方式是先把资源转成File路径再用文件系统API去访问。前者在开发环境、jar包环境都稳如老狗后者在jar包环境下基本必挂。2. 六种读取方法实测代码、输出与适用场景下面六种方法我都拿一个实际场景来演示读取src/main/resources/config/template.json这个文件然后把读到的字符串打印出来。文件内容我随便写一段JSON。2.1 最正统ClassPathResourceSpring框架自己提供的一个资源抽象类也是我本人最推荐的方式。它不需要注入任何东西直接new就能用。import org.springframework.core.io.ClassPathResource; import org.springframework.util.StreamUtils; import java.nio.charset.StandardCharsets; public String readWithClassPathResource() throws IOException { ClassPathResource resource new ClassPathResource(config/template.json); try (InputStream is resource.getInputStream()) { return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } }这段代码里ClassPathResource构造方法传的是classpath下的相对路径也就是相对于src/main/resources根目录。它内部会去调用classloader的getResource方法定位资源然后拿到输入流。为什么我把ClassPathResource排第一因为它是Spring的亲儿子和SpringBoot的主旋律一致甚至在普通的非Spring工程里只要classpath里有spring-core包它都可以直接用。而且它的行为非常透明不管你在IDE里运行还是打成jar包运行它都依赖classloader去识别资源底层自动兼容file协议和jar协议不太需要关心环境差异。这里有个细节ClassPathResource默认不支持以斜杠开头的路径比如/config/template.json如果你写错了资源是找不到的。我实际见过有同事因为习惯了Class.getResource那种带斜杠的写法在ClassPathResource里也加了个斜杠结果查了半天。注意ClassPathResource要传不带前导斜杠的classpath直通路径。2.2 ClassLoader.getResourceAsStream用类加载器绕开路径问题这是我见过在传统Java Web项目里最常见的写法。它的核心是让类加载器按classpath规则去找资源然后直接给流。public String readWithClassLoader() throws IOException { try (InputStream is this.getClass().getClassLoader() .getResourceAsStream(config/template.json)) { if (is null) { throw new FileNotFoundException(未找到资源: config/template.json); } return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } }这里必须说一下注意事项。很多人会误以为ClassLoader.getResourceAsStream和Class.getResourceAsStream是同一个东西其实不是。ClassLoader的版本路径永远相对于classpath根目录也就是说你不能写config/template.json时前面带斜杠带了斜杠反而找不到而Class的版本下一个小节斜杠意义完全不同。另外一个容易踩的坑getResourceAsStream在找不到资源的时候返回的是null而不是抛异常所以在拿到InputStream之前一定要判空。我见过不少线上空指针问题就是漏了这一步系统在资源缺失时给了一个很抽象的NPE而不是文件不存在这种直观提示。还有一个关于类加载器的点使用this.getClass().getClassLoader()在当前绝大多数SpringBoot场景下没问题但极端情况下如果你让某个类由BootstrapClassLoader加载比如引入了某些基础库时getClassLoader()可能返回null这时再调getResourceAsStream就会NPE。更稳妥的做法是拿Thread.currentThread().getContextClassLoader()尤其是你在自己写框架、要跨模块加载资源时线程上下文类加载器更可靠。public String readWithContextClassLoader() throws IOException { try (InputStream is Thread.currentThread().getContextClassLoader() .getResourceAsStream(config/template.json)) { if (is null) { throw new FileNotFoundException(未找到资源: config/template.json); } return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } }用线程上下文类加载器读取resource目录对我来说就是那种看起来其貌不扬、实际极其耐打的通用型方案不需要任何Spring依赖也能跑。2.3 Class.getResourceAsStream加不加斜杠两个世界这个方法初看和使用ClassLoader差不多但路径语义完全不同也是我在帮同事排查问题时发现误解最严重的一个方法。先说代码public String readWithClass() throws IOException { try (InputStream is this.getClass() .getResourceAsStream(/config/template.json)) { if (is null) { throw new FileNotFoundException(未找到资源: /config/template.json); } return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } }注意Class.getResourceAsStream的路径分成两种规则路径以/开头表示相对于classpath根目录等同于前面ClassLoader版本的效果。路径不以/开头表示相对于当前类所在的包来解析。比如类在com.example.utils包下你写config/template.json它实际会去找com/example/utils/config/template.json。因为这个差异同一段代码在不同包下运行结果完全不一样。所以我个人对这个方法的建议是要么明确写清以斜杠开头的完整classpath路径要么就干脆用ClassLoader版本彻底避开相对包的歧义。额外提醒一下上面例子里的斜杠写法在很多教程里都能看到但有不少人在网上搜到的代码是config/template.json没加斜杠这种写法在当前类位于根路径包下时偶尔碰巧能跑通一重构包名就废了。别问我怎么知道的。2.4 依靠容器注入ResourceLoaderSpring的ApplicationContext本身实现了ResourceLoader接口所以在SpringBoot的Bean里你可以直接把ResourceLoader注入进来用。这种做法的优势在于ResourceLoader不仅仅支持classpath:前缀还支持file:、http:等其它前缀以后如果你想把配置从classpath下改成外部文件系统绝对路径代码不用动只需要改一下前缀字符串。import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; Service public class TemplateService { private final ResourceLoader resourceLoader; public TemplateService(ResourceLoader resourceLoader) { this.resourceLoader resourceLoader; } public String readWithResourceLoader() throws IOException { Resource resource resourceLoader.getResource(classpath:config/template.json); try (InputStream is resource.getInputStream()) { return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } } }这里我建议用构造器注入而不是字段注入习惯上更干净也方便单元测试时替换mock。如果你是用Lombok也可以直接用RequiredArgsConstructor生成构造器。需要说明的是调用resourceLoader.getResource(classpath:config/template.json)最终拿到的Resource实际就是一个ClassPathResource实例所以它和第一种方法在本质上是同一套底层机制。但好处是代码里不直接new具体实现类抽象层次更高后续扩展的灵活性更大。2.5 最简洁Value直接注入Resource有些场景下你只是想在某个配置类或工具类里一次性读取一个固定的资源文件那连注入ResourceLoader都省了直接用Value把Resource注进来。Service public class TemplateService { Value(classpath:config/template.json) private Resource templateResource; public String readWithValueAnnotation() throws IOException { try (InputStream is templateResource.getInputStream()) { return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } } }写起来确实舒服但有几个点要说清楚。Value(classpath:config/template.json)这里的值是Spring表达式里的资源路径Spring容器启动时会调用ResourceEditor实际是ResourceConverter那套机制把字符串转换成Resource对象。如果你在测试环境下手new这个BeanResource不会被自动填充你必须自己手动赋值。这也是为什么我倾向于把读取资源封装成方法而不是字段依赖的原因之一可测性会更好。另外Value注入Resource还有一个优势你甚至可以把外部文件的路径也通过配置文件注入进来。比如在application.yml里写my: template-path: classpath:config/template.json然后在配置类里写Configuration public class MyConfig { Value(${my.template-path:classpath:config/template.json}) private Resource templateResource; }这样运维人员可以通过外部的application.yml直接修改资源位置不用重新打包。这种配置优先、代码兜底的思路在实际项目里是很实用的。2.6 反面教材File方式读取只适合类路径在文件系统时最后这个方法是很多老项目里的定时炸弹。它长这样public String readWithFile() throws IOException { File file ResourceUtils.getFile(classpath:config/template.json); return Files.readString(file.toPath(), StandardCharsets.UTF_8); }这段代码在IDE里运行时会很成功地输出内容因为classpath此刻确实映射到了target/classes这个物理目录下ResourceUtils.getFile可以返回一个真实存在的文件。但打成jar包部署到服务器后ResourceUtils.getFile会直接抛出FileNotFoundException因为它内部要求资源URL协议必须是file:而jar包内资源的URL协议是jar:。用大白话说你拿着一张zip压包里的结构图却想让操作系统把它当成真实目录去打开肯定打不开。同样的道理也适用于下面这种写法File file new File(this.getClass().getClassLoader() .getResource(config/template.json).toURI());如果你只是偶尔在main方法里调试用一用那没问题但你要是把它写进生产代码建议尽早改成InputStream方案。我接手过一个老项目里面导出Excel模板就是这么干的开发环境一切正常一部署到Linux上的jar包就傻眼足足排查了好久才定位到是这里。真正的原因是文件资源的URL在IDE环境是file:/xxx/target/classes/config/template.json一到jar包就变成jar:file:/xxx/app.jar!/BOOT-INF/classes/config/template.jsonFile根本不认识这种协议。所以如果你需要的只是读出文件内容请忘记File。File是给操作系统文件用的不是给classpath资源用的。3. 打包成jar之后的现实File方式为什么直接报废InputStream为什么稳3.1 jar包本质是zip没有真实文件路径我在第一部分已经点过一句这里再往深处说一点。SpringBoot默认打的fat jar可执行jar包结构在1.x和2.x时代基本一致都是一层BOOT-INF再包一层Spring Boot自带的LauncherBOOT-INF/classes BOOT-INF/lib org/springframework/boot/loader如果你解压这个jar包会看到一个完整的zip目录树。但是在运行时Java是把jar当作一个zip归档文件来加载的JVM提供的ClassLoader知道怎么在里面找.class文件和资源但只要你想用标准文件系统API比如File、Files、FileInputStream去访问对不起系统根本不把jar包内部当成一个目录挂载点。这里有一个生活上的类比你把一个压缩包发给别人压缩包内部有folder/README.txt这样一个路径但你不能跑到操作系统的文件管理器里输入C:/someone/received/folder/README.txt去访问它。除非你先把它解压出来否则那个路径是不存在的。jar包内部资源就是同一个概念。所以一切可靠读取方案本质上都是绕过文件系统路径直接用带有classpath语义的API把资源定位并打开成InputStream。这也就是为什么前五种方法在jar包环境里都能正常干活的原因。3.2 jar内读取的正解让InputStream替你扛住拿ClassPathResource来说它内部定位资源时拿到的是一个URL类似jar:file:/app/demo.jar!/BOOT-INF/classes/config/template.json之后它调用url.openStream()这个调用链路会走到JarURLConnectionJarURLConnection内部会打开jar文件、定位到指定entry然后返回一个针对该entry的InputStream。你从头到尾都不需要知道这个jar包在磁盘上的绝对路径不需要解压它也不需要操作File一切由Java的jar协议帮你搞定。所以修正前面出现过的反面教材正确写法应该是public String readTemplateSafely() throws IOException { ClassPathResource resource new ClassPathResource(config/template.json); try (InputStream is resource.getInputStream()) { return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } }这个写法在IDE、jar包、Docker镜像里跑起来都没有差别核心逻辑就是一条资源定位交给classloader内容读取交给InputStream路径解析交给协议内部。你无需关心运行环境差异。3.3 批量读取ResourcePatternResolver生产项目里更常见的场景是读取某种类型的所有文件比如扫描classpath下所有mybatis的mapper XML或者读取某个目录下的所有模板。如果用上面单文件的方式一个一个列举代码会很呆而且新加一个文件就要改代码。Spring提供了一个非常好用的ResourcePatternResolver默认实现叫PathMatchingResourcePatternResolver。public ListString readAllSqlScripts() throws IOException { PathMatchingResourcePatternResolver resolver new PathMatchingResourcePatternResolver(); Resource[] resources resolver.getResources(classpath:sql/*.sql); ListString contents new ArrayList(); for (Resource resource : resources) { try (InputStream is resource.getInputStream()) { contents.add(StreamUtils.copyToString(is, StandardCharsets.UTF_8)); } } return contents; }注意它这里第一个参数是classpath:前缀加Ant风格的通配符。注意它这里第一个参数是classpath:前缀加Ant风格的通配符。*只匹配一层**可以匹配多层。如果你想在jar包内扫描多个目录层级用classpath:mapper/**/*.xml这种写法就非常顺滑。PathMatchingResourcePatternResolver底层会借助classloader的getResources注意是复数这个方法能够返回所有匹配路径的资源。在jar包场景下它也是通过遍历jar内部所有条目来匹配的所以放心用于生产环境。不过要小范围提醒一句通配符扫描在jar包里比在目录环境里更耗时因为目录直接list就能搞定而jar包得遍历所有entry去做字符串匹配。如果文件特别多扫描量确实会涨。但如果只是把通配符模式局限在一个相对明确的目录下比如classpath:sql/**/*.sql性能影响基本不用管。如果你在SpringBoot环境里还可以直接把ResourcePatternResolver注进来或者直接按classpath通配符注入Resource数组Value(classpath:sql/*.sql) private Resource[] sqlResources;是不是更简洁但字段注入的适用面你心里要清楚它只适合在Bean生命周期内工作单元测试里如果没启动Spring容器这些字段就是null。4. 从实践里攒出来的选型建议和几个容易翻车的细节4.1 六种方法怎么选一张表格说清楚这里把六种方法放到一张表格里对照方便你在实际项目里快速做决定。方法代码复杂度jar包环境外部依赖推荐场景备注1. ClassPathResource低可靠需要spring-core优先推荐通用性强路径不能以斜杠开头2. ClassLoader.getResourceAsStream低可靠无非Spring环境、工具类返回null要判空3. Class.getResourceAsStream低可靠无小场景是否带斜杠语义完全不同容易误用4. 注入ResourceLoader低可靠需要Spring需要支持多种资源前缀时抽象层次更高5. Value注入Resource最低可靠需要Spring固定资源、配置类读取测试时字段可能为null6. File方式低不可靠无仅限开发环境调试线上代码尽量不要出现综合下来我的习惯是如果是SpringBoot项目普通工具类里直接用ClassPathResource如果是需要把资源来源做成可配置的用ResourceLoader如果是固定资源的注入考虑Value如果是非Spring的纯Java项目用ClassLoader.getResourceAsStream。4.2 容易踩的坑null、编码和中文路径这几个坑每一个都值一次线上事故分享出来供你避雷。第一个既然前面提到了getResource有可能返回null那我再强调它的连锁反应。不要在拿到URL之后立刻调用.getFile()一旦资源不存在后面的NPE会让日志变得极其不好懂。正确的防御姿势是尽早把资源不存在转成有业务含义的异常。第二个编码问题。读resource文件时InputStream拿到的是原始字节流如果你用new String(bytes)默认用的是平台默认字符集在Linux服务器上大概率是UTF-8在Windows上可能是GBK于是你在本地读得好好的中文部署到Linux后就变成乱码。要固定用StandardCharsets.UTF_8去解码。Spring的StreamUtils.copyToString在没指定字符集时也是用平台默认值所以务必带上StandardCharsets.UTF_8参数。第三个中文路径。如果模板文件放在带中文路径的目录下比如config/模板/template.jsonClassLoader在定位资源时返回的URL会对中文做百分号编码导致路径可读性很差但如果只用InputStream方式读取一般没问题因为URLConnection会自动解码反而是转成File对象之后很容易踩到URL编码和多平台路径分隔符的坑。所以规则还是一样能用InputStream就别去碰File。第四个大文件。resource目录下如果放了比较大的文件几十MB以上用readAllBytes或者StreamUtils.copyToString整读进字符串内存占用会非常明显一次读个十几MB可能问题不大但你要是放在高并发接口里每请求读一次GC压力马上就上来了。建议在初始化阶段把它读入内存并缓存或者改用流式处理。4.3 我项目里已经沉淀的读取工具方法最后分享一个我在项目里用的工具方法它封装了classpath资源读取为String这个操作输出你直接拷贝就能用import org.springframework.core.io.ClassPathResource; import org.springframework.util.StreamUtils; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; public static String readClasspathFile(String path) { ClassPathResource resource new ClassPathResource(path); try (InputStream is resource.getInputStream()) { return StreamUtils.copyToString(is, StandardCharsets.UTF_8); } catch (IOException e) { throw new IllegalArgumentException(读取classpath资源失败: path, e); } }如果你不依赖Spring也可以自己写一个等价版本public static String readClasspathFile(String path) throws IOException { try (InputStream is Thread.currentThread().getContextClassLoader() .getResourceAsStream(path)) { if (is null) { throw new FileNotFoundException(classpath下找不到文件: path); } byte[] buffer new byte[is.available()]; int read; ByteArrayOutputStream baos new ByteArrayOutputStream(); while ((read is.read(buffer)) ! -1) { baos.write(buffer, 0, read); } return baos.toString(StandardCharsets.UTF_8.name()); } }这个Java 8兼容版本的代码逻辑是循环读取到内存Java 9以上可以直接用is.readAllBytes()更简洁。要注意is.available()返回的是当前可读字节数并不可靠地代表整个文件大小所以我在循环里不用它作为结束条件只把它当初始缓冲区大小。我在实际工单处理里最喜欢的还是第一段那个Spring版本的封装因为它连流的关闭都帮我们放在try-with-resources里做掉了出错信息也非常直观。如果你在别的组件里要复用直接扔进一个静态方法或者工具类就行。再说一个真实项目里扩展出来的经验如果你的resource目录下的文件是每次发布都可能变的模板比如公司内部的通知模板、合同模板只依赖jar包里的版本会很被动。这时候比较好的做法是jar包内置默认模板但在应用启动时先检查外部配置目录如果外部目录存在同名文件就优先读外部文件没有外部文件才回退到classpath内的默认版本。这样既保证了开箱即用也照顾了线上改模板不重新发版的需求。核心就是对外部路径用File方式读对classpath内部用InputStream方式读两条链路分开处理代码里分别封装方法不要在中间混杂。这些天里我反复处理过类似读不到文件的问题总结下来resource文件的读取从来不是难在API数量而是难在能不能理解文件在jar包内外是两种完全不同的存在形态。只要理解了这一点六种方法在脑子里就自动分成了两大阵营InputStream阵营永远可靠File阵营只适合开发环境。下次再遇到线上文件读取失败先不要慌看看代码里是不是出现new File关键字如果是十有八九就是它了。