
平时用 Spring Boot 做项目经常遇到需要读取resources目录下配置文件、模板文件或静态资源的需求。这事看似简单实际上暗坑不少开发环境跑得好好的打 jar 包部署就报文件找不到路径写错一个斜杠IDEA 里正常Linux 服务器上直接 NullPointerException。我整理了 9 种实测可行的读取方式从原理到代码一步步拆开讲清楚顺便把常见的坑都帮你踩一遍。1. 先从根源说起resources目录编译后去哪了在 Spring Boot 项目中src/main/resources目录下的所有文件在打包阶段会被复制到target/classes目录里。而target/classes这个路径就是 classpath类路径的根目录。项目通过mvn spring-boot:run或 IDEA 直接运行时classpath 指向的就是target/classes打成 jar 包后classpath 变成 jar 包内部的结构。所以无论用哪种方式读取本质上都是在找 classpath 下的资源路径。src/main/resources/application.yml 编译后 - target/classes/application.ymlclasspath 根目录 打包后 - BOOT-INF/classes/application.ymljar 内的 classpath 根目录理解这一点是关键你要读取的文件不在磁盘的真实路径里而在 classpath 这个逻辑路径里。这就引出了所有读取方式的核心分歧——你是站在文件系统视角找文件还是站在 classpath 视角找资源。2. 九种读取方式逐一拆解代码、原理与适用场景2.1 ClassPathResourceSpring 最正统的读取方式Spring 框架自己封装了一个ClassPathResource类专门处理 classpath 下的资源读取这是我最推荐的方式没有之一。它内部基于类加载器实现对 Spring Boot 的各种运行环境兼容性最好。import org.springframework.core.io.ClassPathResource; import org.springframework.util.FileCopyUtils; import java.io.InputStream; import java.nio.charset.StandardCharsets; public String readClassPathResource(String filePath) throws IOException { ClassPathResource resource new ClassPathResource(filePath); try (InputStream inputStream resource.getInputStream()) { byte[] bytes FileCopyUtils.copyToByteArray(inputStream); return new String(bytes, StandardCharsets.UTF_8); } } // 调用示例 String content readClassPathResource(templates/email-template.html);关键细节构造ClassPathResource时路径不要以/开头直接写相对于 classpath 根目录的路径。比如要读resources/templates/a.txt传参就是templates/a.txt。它的底层兼容性最好无论是 IDEA 运行、java -jar运行还是java -cp运行都能应对因为 Spring 在创建应用上下文时已经做了各种类加载器环境的适配。2.2 ResourceLoader借助 Spring 容器的资源解析能力在 Spring 管理的 Bean 中可以直接注入ResourceLoader然后通过它加载资源。ResourceLoader接口是 Spring 资源加载策略的顶层抽象应用上下文本身就是一个ResourceLoader。import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; import org.springframework.util.FileCopyUtils; import java.io.InputStream; import java.nio.charset.StandardCharsets; Component public class ResourceLoaderService { private final ResourceLoader resourceLoader; public ResourceLoaderService(ResourceLoader resourceLoader) { this.resourceLoader resourceLoader; } public String readResource(String location) throws IOException { // 注意这里要用 classpath: 前缀 Resource resource resourceLoader.getResource(classpath: location); try (InputStream inputStream resource.getInputStream()) { byte[] bytes FileCopyUtils.copyToByteArray(inputStream); return new String(bytes, StandardCharsets.UTF_8); } } }为什么推荐这种方式ResourceLoader不只是能读 classpath还支持file:、url:等前缀相当于给你留了后路。假如哪天配置移到外部文件系统了你只需要改前缀不用改业务代码。另外测试的时候也很方便可以非常容易地注入 mock 对象。2.3 ResourcePatternResolver批量读取和通配符匹配的利器如果你需要读取多个匹配同一规则的文件比如读取i18n/目录下所有messages_*.properties文件用ResourcePatternResolver配合classpath*:前缀一行代码搞定。import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternResolver; import java.io.InputStream; public ListString readPatternResources(String pattern) throws IOException { ResourcePatternResolver resolver new PathMatchingResourcePatternResolver(); // 例如: classpath*:i18n/messages_*.properties Resource[] resources resolver.getResources(pattern); ListString 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 包里的。在多模块项目或依赖了多个 jar 包时classpath*:才能匹配全。2.4 File 方式直接读取最直观但坑最深的方式这种方法一看就懂一用就废特别是在生产环境// 极其不推荐仅限本地开发调试 File file new File(src/main/resources/data.txt); FileReader reader new FileReader(file);new File(src/main/resources/...)的路径是基于当前工作目录的。IDEA 里运行时工作目录恰好是项目根目录所以能读到。但打成 jar 包后工作目录变成 jar 所在目录src/main/resources根本不存在直接 FileNotFoundException。如果非要用 File 方式正确姿势是拼接 classpath 绝对路径// 取得 target/classes 的绝对路径 String path Objects.requireNonNull( Thread.currentThread().getContextClassLoader().getResource()).getPath(); File file new File(path data.txt);这种方式能在开发环境跑通但 jar 包场景依然可能出问题因为 jar 内的资源路径不是真实文件路径。我的结论是能不用 File 就不用 File。2.5 Class.getResourceAsStream基于字节码定位的类路径读取这种方式利用 JVM 的字节码加载机制通过类的字节码所在位置来定位同路径下的资源。它有个特性相对路径基于类的包路径不是 classpath 根目录。public class ResourceReadExample { // 相对路径基于当前类的包路径 public String readRelativePath() throws IOException { // 假设该类的包是 com.example.demo // 这条路径实际寻找的是 classpath:com/example/demo/config.json try (InputStream is getClass().getResourceAsStream(config.json)) { if (is null) { throw new FileNotFoundException(资源不存在); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } // 绝对路径从 classpath 根目录开始找 public String readClasspathPath() throws IOException { try (InputStream is getClass().getResourceAsStream(/data/config.json)) { if (is null) { throw new FileNotFoundException(资源不存在); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } }核心规则路径开头加/表示从 classpath 根目录开始找不加/表示从当前类所在包路径开始找。这是容易出错的关键点很多人在这里踩坑。2.6 ClassLoader.getResourceAsStream最稳定的底层方案ClassLoader没有包路径概念所以所有路径都是相对于 classpath 根目录。这也是为什么它比Class.getResourceAsStream更简单直接——不用纠结加不加斜杠。public String readViaClassLoader(String relativePath) throws IOException { // 注意路径不能以 / 开头否则会报 IllegalArgumentException ClassLoader classLoader Thread.currentThread().getContextClassLoader(); if (classLoader null) { classLoader ResourceReadExample.class.getClassLoader(); } try (InputStream is classLoader.getResourceAsStream(relativePath)) { if (is null) { throw new FileNotFoundException(资源不存在: relativePath); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } // 调用示例 String content readViaClassLoader(templates/email-template.html);为什么优先使用线程上下文类加载器在 Web 应用或复杂类加载器环境下ClassLoader.getResourceAsStream可能拿不到业务资源但Thread.currentThread().getContextClassLoader()通常指向应用自己的类加载器能正确加载内部资源。这是高并发 Web 场景下的经验之谈。如果这段代码在 Spring 管理的 Bean 里也可以直接用getClass().getClassLoader()优先级相对低一些。2.7 ServletContextWeb 项目专属的读取方式如果你的 Spring Boot 应用部署在外置 Tomcat 等容器里ServletContext提供了一种额外的读取途径。注意内嵌容器模式默认 jar 启动下不建议用因为 ServletContext 的资源根目录通常映射到 WAR 包解压后的WEB-INF/classes/目录。import javax.servlet.ServletContext; import org.springframework.web.context.ContextLoader; import org.springframework.web.context.WebApplicationContext; public String readViaServletContext(String pathInWebInfClasses) throws IOException { // 获取 ServletContext 的常见方式 ServletContext servletContext ContextLoader.getCurrentWebApplicationContext().getServletContext(); // 路径必须以 / 开头相对于 WEB-INF/classes 根目录 try (InputStream is servletContext.getResourceAsStream(/ pathInWebInfClasses)) { if (is null) { throw new FileNotFoundException(资源不存在); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } // 调用示例 String content readViaServletContext(WEB-INF/classes/config/app.properties);这种方式更多出现在传统 Spring MVC WAR 包部署的项目里。Spring Boot 默认为内嵌容器实际工作中用得少但因为是 Web 项目的经典方案值得了解。2.8 FileSystemResource 与 UrlResource从外部路径补位这两个都是 Spring 的 Resource 实现其中FileSystemResource负责文件系统路径UrlResource负责 URL 协议路径。它们的主要定位是读取 classpath 之外的资源比如服务器上的配置文件目录或远程 URL。import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.UrlResource; import java.net.URL; public String readFromFilePath(String absolutePath) throws IOException { // 读取文件系统绝对路径下的文件 FileSystemResource resource new FileSystemResource(absolutePath); try (InputStream is resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } public String readFromUrl(String urlString) throws IOException { // 读取远程资源比如 http://example.com/config.json UrlResource resource new UrlResource(new URL(urlString)); try (InputStream is resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } }它们不直接解决读 classpath 资源的问题但经常和 2.1、2.2 配合使用——先通过 classpath 定位到文件拿到绝对路径后再转成FileSystemResource做进一步处理如重新加载、监听变化。2.9 Value 注入最简洁的声明式读取Spring 的Value注解支持classpath:表达式直接把文件内容注入到字段里适合一次性加载配置类文件比如 JSON 模板、SQL 脚本。import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; import org.springframework.util.FileCopyUtils; import javax.annotation.PostConstruct; import java.nio.charset.StandardCharsets; Component public class FileContentHolder { Value(classpath:data/init-data.json) private Resource dataResource; private String dataContent; PostConstruct public void init() throws IOException { try (InputStream is dataResource.getInputStream()) { dataContent new String(FileCopyUtils.copyToByteArray(is), StandardCharsets.UTF_8); } } public String getDataContent() { return dataContent; } }Value(classpath:...)会由 Spring 容器自动完成资源定位和 Resource 封装你只需要手动读取内容。这种方式代码量最少也最不容易出错因为它完全交给 Spring 处理了。唯一要注意的是文件不存在或路径写错时Spring 启动阶段就会报错属于快速失败风格我个人比较喜欢这种方式因为问题暴露得早。3. 读取过程中最常踩的坑斜杠、通配符与打包后路径这部分是我在多个项目中积累的真实踩坑记录每一个都曾经让人头疼不已。3.1 开头到底加不加斜杠一张表看懂全部规则不同 API 对路径开头的/有完全不同语义用错直接导致资源找不到。整理成表格方便对照读取方式路径格式开头带 / 的含义new ClassPathResource(a.txt)不带/若带/会去掉后继续从 classpath 根目录找二者结果一样resourceLoader.getResource(classpath:a.txt)不带/若带/容易拼成classpath:/a.txt也有兼容但建议不写classLoader.getResourceAsStream(a.txt)不带/带/会直接报IllegalArgumentExceptionClass.getResourceAsStream(/a.txt)带/相对 classpath 根目录Class.getResourceAsStream(a.txt)不带/相对当前类所在包目录servletContext.getResourceAsStream(/WEB-INF/classes/a.txt)必须带/相对 Web 应用根目录看到没ClassLoader和Class对斜杠的处理完全相反这是最容易被搞混的地方。我的习惯是统一使用 ClassPathResource 或 ClassLoader 方式并且一律不带开头斜杠这样记忆成本最低。3.2 classpath 与 classpath* 的区别classpath:是 Spring 的资源定位语法只找第一个匹配到的 classpath 根classpath*:会扫描所有 classpath 路径包括依赖 jar 包。在多模块 Maven 项目或引入了多个公共 Jar 包时这两者差距巨大// 假设你依赖了两个 jar里面都有 config.xml // classpath:config.xml 只返回第一个 Resource r resourceLoader.getResource(classpath:config.xml); // classpath*:config.xml 返回所有匹配到的 Resource[] all resolver.getResources(classpath*:config.xml);当你要读取某个 jar 包内的资源时用classpath*:才能定位到位。尤其在 Spring Boot 集成 MyBatis、Flowable 等框架时如果 Mapper XML 或流程定义文件分布在不同 jar 包里必须用classpath*:。还有一个坑classpath:不支持在路径中使用通配符*、**、?只有ResourcePatternResolver配合classpath*:才支持通配符匹配。设计上ResourceLoader.getResource()就不是干这个的。3.3 打成 jar 包后文件找不到的根因与解决方案这是 Spring Boot 里最经典的生产事故之一。你在 IDEA 里运行一切正常部署到服务器执行java -jar app.jar后程序报FileNotFoundException或NullPointerException因为 getResourceAsStream 返回 null。根因在 IDEA 运行时classpath 是文件系统目录target/classes你可以直接用new File()去访问但 jar 包内部其实是一个 ZIP 压缩结构Spring Boot会把依赖和内部类分别放在BOOT-INF/lib/与BOOT-INF/classes/下。这些路径对普通文件系统 API 来说不是真实存在的文件路径用new File()访问必然会失败。解决方案统一使用基于流的方式读取比如ClassPathResource、ClassLoader.getResourceAsStream。这些方式通过类加载机制打开资源流不依赖文件系统路径对 jar 包同样有效。以下是 jar 包和非 jar 包都通用的完整示例public String readConfigFromJar(String fileOnClasspath) throws IOException { ClassPathResource resource new ClassPathResource(fileOnClasspath); if (!resource.exists()) { throw new IOException(资源不存在: fileOnClasspath); } try (InputStream is resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } }提示如果你只是读取 Properties 文件可以直接使用 Spring 的PropertiesLoaderUtils.loadAllProperties(config.properties)它内部封装了 classpath 的搜索逻辑会自动兼容 IDEAR 运行和 jar 包运行两种模式。3.4 读取到的文件是乱码或内容为空乱码问题通常是编码不一致导致的。resources下的文件我在新项目中统一要求使用 UTF-8 编码。读取时如果直接new String(bytes)会使用平台默认编码Linux 服务器默认通常是 UTF-8但 Windows 默认可能是 GBK就会出现本地正常、服务器乱码的诡异症状。正确的做法是显式指定字符集new String(bytes, StandardCharsets.UTF_8);读写都要保持一致文件本身是 UTF-8 保存读取也用 UTF-8 解码。如果是从别人手里接过来的旧项目不确定文件是什么编码可以在 IDEA 右下角看到文件编码再对症读取。内容为空的另一种情况是路径写对了文件也找到了但读取的是空资源。这种情况通常是因为误用了new File(classpath:xxx)这种不存在的文件或getResource()注意空字符串返回了目录而非文件导致的。添加非空校验能快速暴露问题if (resource.getInputStream().available() 0) { throw new IllegalStateException(资源内容为空: path); }4. 到底该用哪种方式一套可落地的选型建议九种方式各有优劣但实际开发中不需要每次切换。我给出一个简洁的选型逻辑直接照着用就行场景推荐方案理由读取单个 classpath 文件ClassPathResource最正统兼容性最好代码最简洁Spring Bean 中读取资源ResourceLoader注入方便测试支持多种资源前缀批量匹配多个文件*通配符ResourcePatternResolverclasspath*:唯一支持通配符匹配的 Spring 方案读取配置属性启动即加载Value(classpath:...)注入 Resource声明式启动快速失败出错早暴露读取 jar 内部文件ClassLoader.getResourceAsStream/ClassPathResource流式读取兼容压缩包结构读取外部配置文件不含 classpathFileSystemResource/java.io.File文件系统路径Web 应用WAR 部署ServletContext.getResourceAsStreamWeb 容器统一管理资源路径读取 properties 特别场景PropertiesLoaderUtils.loadAllPropertiesSpring 封装的属性专用加载工具如果是新项目我的个人习惯是业务代码里统一注入ResourceLoader用classpath:前缀读取资源。理由有三点一是与 Spring 生命周期完全契合二是测试时容易 mock三是后续如果要切换外部存储比如 NFS 或云存储只需改资源前缀。如果只是启动时加载一次配置文件用Value注入 Resource 最省事。5. 几点额外的实战经验最后补几个平常不留意但关键时刻能救命的细节。5.1 确保文件被正确打入 classpath有时候代码写得没问题但资源就是找不到。排查第一步优先确认文件是不是真的在target/classes或 jar 里。执行以下命令快速看# 查看 class 目录下是否存在资源 ls -l target/classes/data/config.json # 查看 jar 包内的资源位置 jar tf app.jar | grep config.json如果文件没打进去问题通常出在 Maven 配置上。Spring Boot 的spring-boot-maven-plugin默认会把src/main/resources全部打进 jar但如果你自定义了resources配置或者放在非 resources 目录比如src/main/java下就会出问题。检查pom.xmlbuild resources resource directorysrc/main/resources/directory filteringfalse/filtering /resource !-- 如果有个性化配置确认原路径没有漏掉 -- /resources /build5.2 避免在静态方法里使用类加载器写工具类时常有人图省事用ResourceReadExample.class.getClassLoader()但在某些特殊类加载器环境下如 Tomcat 的WebappClassLoader、自定义类加载隔离getClassLoader()可能返回 null 或加载不到业务资源。更稳妥的做法// 优先使用线程上下文类加载器 ClassLoader contextClassLoader Thread.currentThread().getContextClassLoader(); if (contextClassLoader null) { contextClassLoader YourClass.class.getClassLoader(); }如果是 Spring Boot 的 Web 应用绝大多数情况线程上下文类加载器都是可用的。5.3 如果文件是动态变化的考虑绕过 classpathresources下的文件在打成 jar 后只读不可修改。如果你需要运行期动态更新配置文件比如读取后每次都拿最新内容就别把文件放在 jar 内部的 classpath而是把配置文件放在外部目录通过--spring.config.locationfile:/外部路径/或自定义FileSystemResource去读取。这是生产环境的常见做法可以做到修改配置不重新打包。5.4 性能提示避免频繁读取大文件getResourceAsStream每次都会打开一个新的资源流如果你在高频接口里读取一个大 JSON 文件会白白浪费 IO 和序列化开销。建议采用以下策略启动时将一次性加载的内容缓存到内存中例如用PostConstruct配合Value注入 Resource如果文件可能更新就用Scheduled定时刷新缓存或用 Spring 的RefreshScope配合配置中心只有小文件、低频场景才在业务方法里循环读取。这些经验大多来自实际生产项目的教训总结。资源文件读取本身不难但一旦忽略运行环境的差异开发环境正常、生产环境报错的情况会反复出现。希望这篇总结能帮你少踩几个坑。