ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot读取resources目录文件:9种方式与JAR包路径避坑指南

Spring Boot读取resources目录文件:9种方式与JAR包路径避坑指南 Spring Boot 项目读取 resources 目录下的文件这件事我从入行起就绕不开。几乎每隔几周群里就会有人发一个 FileNotFoundException然后各路说法都有有人说是路径不对有人说是编码问题有人说是打包配置缺了文件。其实这个问题一点都不神秘核心就一句话搞懂 resources 目录在编译和运行时的真实形态你就知道该用什么方式去读。这篇文章我从头整理了 9 种可行方式覆盖 JDK 原生 API、Spring 的 Resource 抽象、以及常见第三方工具类封装每一种都带代码和适用场景。不管你是刚写 Spring Boot 不久还是准备面试前刷一遍基础点这篇都值得收藏。1. 先搞清楚resources 目录下的文件到底去哪儿了1.1 编译后文件被“搬家”了所有 Maven 或 Gradle 项目里的src/main/resources目录在编译阶段都会被复制到输出目录。Maven 默认是target/classesGradle 默认是build/resources/main。这个输出目录就是 JVM 的 classpath 根目录也就是.class文件所在的根目录。所以你在源码里写的config/app.properties编译后真实位置是target/classes/config/app.properties这意味着你在 IDEA 里可以直接看到target/classes下躺着这些文件它们确实是操作系统文件系统上的真实文件。很多人第一次踩坑就是因为在本地用new File(target/classes/config/app.properties)确实能读到于是开始放飞自我直接按文件系统路径写死。1.2 IDE 运行与 JAR 包运行的本质差异本地运行和打包部署两种形态完全不同运行形态resources 文件位置能否直接 new FileIDEA / Eclipse 直接运行target/classes 下真实目录可以但非常不推荐mvn package 后 java -jarBOOT-INF/classes 下的 jar 内部路径不行肯定报错Docker 镜像内运行同上jar 包内部不行Spring Boot 的可执行 JAR 是一个 zip 包项目自己的类路径集中在BOOT-INF/classes下依赖在BOOT-INF/lib下。这个结构是 Spring Boot 自定义的它让java -jar可以独立运行但也意味着这些资源文件并不是文件系统上的真实文件路径而是“压缩包内部的一个条目”。当你用下面这段代码在 JAR 包里尝试读取一个文件时// 错误示范 URL url this.getClass().getClassLoader().getResource(config/app.properties); File file new File(url.toURI()); // 抛 IllegalArgumentException: URI is not hierarchical都会失败。因为url的协议是jar不是file它指向的是压缩包内部的路径和操作系统的文件系统没有直接关系。1.3 最稳的策略永远面向 InputStream 编程既然文件在两种形态下的物理载体不同那最稳定的做法就是不要关心它在哪个物理位置直接用InputStream获取内容。所有资源访问的本质是先定位到 classpath 中的资源再以流的方式读取。classpath 可以理解成一个抽象的虚拟文件系统不管它背后是target/classes目录还是 JAR 包内部都可以通过“类加载器”这一层来统一访问。这也是下面 9 种方式里所有稳定方式的核心逻辑。只要坚持“面向流编程”你就基本避开了 90% 的路径坑。2. 原生类加载器系四种最朴素的读法不用 Spring 的任何能力完全用 JDK 自带的Class和ClassLoaderAPI 就能读取 resources 文件。这一类方式是最底层的很多框架源码内部的资源读取也是这么干的。2.1 方式一Class.getResourceAsStream最常用的快速读法这是写起来最简单的方式代码就三五行// 注意/ 开头表示 classpath 根目录 try (InputStream is MyService.class.getResourceAsStream(/config/app.properties)) { if (is null) { throw new FileNotFoundException(config/app.properties not found); } Properties props new Properties(); props.load(is); System.out.println(props.getProperty(app.name)); }这里最重要的就是路径规则。Class.getResource的路径解析规则是以/开头相对于 classpath 根目录不以/开头相对于当前类所在的包目录举个例子如果你的类在com.example.util包下// 相对于 com/example/util 包目录 getResourceAsStream(config/app.properties) // 相对于 classpath 根目录 getResourceAsStream(/config/app.properties)第二种写法和第一种定位到的位置完全不一样这也是初学者最容易懵的地方之一。这种方式的优点是代码短不需要额外的类缺点是getResourceAsStream找不到资源时返回null而不是抛异常所以需要手动判空。读取文本内容时务必用InputStreamReader指定字符集直接new String(bytes)会吃平台默认编码的亏。2.2 方式二Class.getResource拿到 URL 再决定怎么处理和第一种方式本质相同区别是先返回一个URL对象再由你自己决定怎么打开URL url MyService.class.getResource(/config/app.properties); if (url null) { log.warn(资源不存在); return; } try (InputStream is url.openStream(); BufferedReader reader new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { String content reader.lines().collect(Collectors.joining(\n)); // ... }拿到URL的好处是灵活。比如你可以用它判断资源是否存在可以把完整 URL 打印出来排查问题也可以在某些需要传 URL 参数的场景下直接用。但这里有一个非常重要的禁忌拿到 URL 后不要轻易调url.getFile()再转File。JAR 包环境里url.getFile()返回的路径长这样file:/opt/app.jar!/BOOT-INF/classes/config/app.properties用new File()去操作这种东西大概率直接崩。所以从URL读取内容时永远优先用openStream()。2.3 方式三ClassLoader.getResourceAsStream工具类最爱ClassLoader层面的 API 和Class有一个关键区别路径不能以/开头永远相对于 classpath 根目录。ClassLoader cl Thread.currentThread().getContextClassLoader(); try (InputStream is cl.getResourceAsStream(config/app.properties)) { if (is null) { throw new IllegalArgumentException(Resource not found: config/app.properties); } String content new String(is.readAllBytes(), StandardCharsets.UTF_8); // ... }这种方式在写工具类、静态方法时特别好用因为不需要持有某个类引用。读取路径的写法也统一了不带/语义清晰。关于用哪个 ClassLoader业内有个常用选择Thread.currentThread().getContextClassLoader()即线程上下文类加载器。它在 Web 容器、动态类加载等场景下通常能正确加载到应用类路径。如果你想完全可控用YourClass.class.getClassLoader()也可以但注意如果是被系统类加载器加载的类getClassLoader()可能返回null这时候调用getResourceAsStream反而会直接 NPE。还有一点InputStream.readAllBytes()是 Java 9 才有的方法。如果你的项目还停留在 Java 8需要自己拼ByteArrayOutputStream或者用工具类封装。2.4 方式四ClassLoader.getResource与前者的姐妹版本和方式三的对应关系就等同于方式二和方式一的关系ClassLoader cl MyService.class.getClassLoader(); URL url cl.getResource(config/app.properties); if (url null) { throw new FileNotFoundException(config/app.properties not found); } try (InputStream is url.openStream()) { // 处理内容 }有同学问这种方式有什么独特价值。其实最大的价值就是当你需要同时处理“多个同名资源”时ClassLoader只有获取“一个”资源的能力拿到的永远是 classpath 顺序里排在最前面的那个。如果多个 jar 里有同名文件真正生效的总是第一个。所以方式四适合那些“我只需要找一个资源拿到 URL 后自己决定后续操作”的场景。它比方式三多一步灵活性高一点但代价是要自己处理null和流关闭。2.5 四种原生方式怎么选这四种方式本质都在做同一件事通过类加载器定位 classpath 资源。它们之间的差异主要体现在路径前缀规则和返回类型上方式路径规则返回值适用场景Class.getResourceAsStream/ 开头是根目录InputStream快速读取内容Class.getResource/ 开头是根目录URL需要 URL后续处理灵活ClassLoader.getResourceAsStream不带 /永远是根目录InputStream工具类 / 静态方法ClassLoader.getResource不带 /永远是根目录URL需要 URL 的基础操作我个人日常写纯 JDK 代码时更倾向用 ClassLoader.getResourceAsStream。原因很简单路径规则唯一不带/不会因为类所在包不同而改变语义。把它封装成一个全局工具方法后无论在哪个类里调用路径写法都是一样的。这里补一个可以直接抄的工具类public final class ClassPathResourceReader { private ClassPathResourceReader() { } public static String readAsString(String classPath) throws IOException { try (InputStream is ClassPathResourceReader.class.getClassLoader() .getResourceAsStream(classPath)) { if (is null) { throw new IllegalArgumentException(Resource not found: classPath); } return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } }这个工具类只要保证项目是 Java 9 就能直接用简单可靠而且 JAR 包环境下也不会翻车。3. Spring 资源抽象更优雅的四种读取方式如果你在用 Spring Boot其实可以不用纠结底层的Class和ClassLoader是谁。Spring 已经把资源访问统一封装成了Resource接口不管资源来自 classpath、文件系统、URL 还是 ServletContext取内容的方式都是同一个getInputStream()。这种统一抽象正是专门为“JAR 包内外差异”这类问题设计的。3.1 方式五Value 注入 Resource最省事的写法Spring 的Value注解可以直接把一个资源路径注入为Resource对象Service public class ConfigService { Value(classpath:config/app.properties) private Resource appPropertiesResource; public String getAppName() throws IOException { try (InputStream is appPropertiesResource.getInputStream()) { Properties props new Properties(); props.load(is); return props.getProperty(app.name); } } }这是 Spring Boot 项目里最推荐的日常写法。只要资源在 classpath 下Spring 就能自动帮你把这个 Resource 对象准备好。代码里不再出现任何 ClassLoader 相关的逻辑非常清爽。注意Value的路径写法是classpath:config/app.properties不是classpath:/config/app.properties两者从 Spring 的 Resource 解析来说都能识别但习惯上写classpath:加相对路径即可。另外一个容易忽略的点Value注入Resource时文件不存在不会在启动阶段立刻报错只有当调用getInputStream()时才会抛异常。所以如果你希望“文件缺失就早暴露”可以在项目启动后执行一次自检比如在ApplicationRunner里读一下文件。3.2 方式六ResourceLoader 动态获取适合路径变化场景ResourceLoader是 Spring 容器的一个基础接口ApplicationContext本身就实现了它。所以你可以直接注入ResourceLoader在运行时动态拼路径Service public class TemplateService { private final ResourceLoader resourceLoader; public TemplateService(ResourceLoader resourceLoader) { this.resourceLoader resourceLoader; } public String readTemplate(String templateName) throws IOException { Resource resource resourceLoader.getResource(classpath:templates/ templateName); try (InputStream is resource.getInputStream()) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } } }为什么需要这种用法因为有很多情况下资源路径是动态拼出来的。比如根据用户请求的不同模板名读取对应模板文件、根据当前环境读取不同目录的配置文件、根据业务类型读取不同的规则脚本。这种场景没法写死在Value里注入ResourceLoader动态获取就很合适。再说个小建议能注入ResourceLoader就不要注入整个ApplicationContext。虽然ApplicationContext也是ResourceLoader但接口最小化是基本功依赖范围越窄代码越容易测试和维护。3.3 方式七new ClassPathResource手动控制最清晰ClassPathResource是 Spring 对 classpath 资源的具体实现你可以在任何地方直接new出来ClassPathResource resource new ClassPathResource(config/app.properties); try (InputStream is resource.getInputStream()) { // 处理文件内容 }它的路径规则和ClassLoader.getResourceAsStream一致默认相对于 classpath 根目录不需要再加/。这种方式的典型使用场景是在非 Spring Bean 环境中读取 classpath 资源。比如单元测试里、定时任务里、或者某些工具类中。它不依赖 Spring 容器也不需要注入任何东西自己创建对象就完了。ClassPathResource还有个便捷方法exists()可以在读取前判断资源是否存在ClassPathResource resource new ClassPathResource(config/app.properties); if (resource.exists()) { // 存在再读取 }有个细节要注意ClassPathResource还提供getFile()方法但这个方法和前面说的url.toURI()一样在 JAR 包环境会失效。稳定做法依然是只调用getInputStream()。3.4 方式八ResourcePatternResolver 批量匹配一次读完所有文件前面几种方式都是读取单个文件。如果我想一次读取config目录下所有.properties文件或者读取某个目录下全部.json模板怎么办这时候就要用到PathMatchingResourcePatternResolverResourcePatternResolver resolver new PathMatchingResourcePatternResolver(); try { Resource[] resources resolver.getResources(classpath*:config/*.properties); for (Resource resource : resources) { try (InputStream is resource.getInputStream()) { // 逐个处理 } } } catch (IOException e) { log.error(读取配置文件失败, e); }这个方式最大的价值在于支持通配符*匹配路径中一个层级内的任意字符**匹配任意层级?匹配单个字符classpath*:扫描所有 classpath包括依赖 jar 里的资源对比一下classpath:和classpath*:classpath:只扫描当前 classpath 中第一个匹配项classpath*:会搜索所有 jar 包和类路径目录下的所有匹配资源。批量场景下必须用classpath*:才能保证不遗漏。这种批量读取最常用的场景是读取 SQL 脚本初始化数据、读取邮件模板、读取文件夹下的全部广告文案、读取规则引擎的规则文件。一次getResources拿回一个数组后续逐个处理省去写 File 遍历目录的逻辑。3.5 Spring Resource 抽象帮你解决了什么问题说到这里你应该已经感受到了Spring 的Resource接口就是为“屏蔽资源来源差异”而生的。你不需要关心资源是在target/classes还是 JAR 包内部只需要拿到Resource对象然后调getInputStream()。这带来的额外收益是单元测试时可以 mockResource不用真的建文件资源来源可以轻松切换classpath、file、url只要改一个前缀和 Spring Boot 的配置体系天然打通从配置中心拿到的占位符也能用来拼接路径如果你的项目里还没有统一使用Resource来读取文件我建议从下一个功能开始尝试一下体验会好很多。4. 第九种方式直接用工具类库封装前面 8 种方式已经覆盖了原生能力和 Spring 抽象但实际开发中我们还经常会看到一类“一行代码读完文件”的写法这就是工具类库的功劳。这类方式不再需要你手动处理流和编码底层本质仍然是类加载器读取但 API 友好度高了不止一个档次。4.1 工具类读取示例Hutool、Guava、Commons IO我平时最常用的三个库分别说一下。Hutool 在国产项目中非常流行它的资源工具类是ResourceUtilimport cn.hutool.core.io.resource.ResourceUtil; // 直接读成字符串默认 UTF-8 String content ResourceUtil.readUtf8Str(config/app.properties);这一行代码就完成了所有事情。ResourceUtil内部会先从 classpath 找找不到再从文件系统找方便是真方便。Guava 的写法也很有意思它更偏向“先拿 URL再做处理”import com.google.common.io.Resources; import java.net.URL; import java.nio.charset.StandardCharsets; URL url Resources.getResource(config/app.properties); String content Resources.toString(url, StandardCharsets.UTF_8);这里有个细节要注意Resources.getResource底层默认使用系统类加载器在某些容器环境下可能拿不到应用类加载器里的资源。稳妥一点的做法是显式指定类加载器URL url Resources.getResource(MyService.class.getClassLoader(), config/app.properties);Apache Commons IO 的IOUtils更多是作为流处理的补强工具import org.apache.commons.io.IOUtils; try (InputStream is MyService.class.getClassLoader() .getResourceAsStream(config/app.properties)) { String content IOUtils.toString(is, StandardCharsets.UTF_8); }它解决的核心问题是“读取流时不用自己拼 ByteArrayOutputStream”在 Java 8 项目里特别实用。如果你的项目升级到了 Java 9Files.readString或InputStream.readAllBytes也可以替代一部分但IOUtils的 API 总体还是更顺手。4.2 为什么把它们归为同一种方式可能有同学会问这不是三种方式吗怎么算第九种一种我是这么理解的它们本质上都是对“类加载器读取 classpath 资源”的封装只是封装的入口不同。Hutool 封装到ResourceUtilGuava 封装到ResourcesCommons IO 封装到IOUtils但底层都逃不开getResourceAsStream或getResource。在实际工程里引入这些库也只是为了少写样板代码并没有引入新的读取机制所以我把它归为一类。用工具类时有一个原则要把握不要为了读一个文件而重新造一个轮子但也别为了省事引一个巨型依赖。如果你的项目已经引入了 Hutool那就直接用ResourceUtil如果项目里已经带了 Guava那Resources也是顺手的事什么都没引入时只用 Spring 自带的ClassPathResource也完全够用没必要为此额外加依赖。5. 常见问题与排查技巧实录这一部分都是我在实际项目里亲眼见过、亲手排查过的问题。有些看着很小但网上描述经常不准这里一次说清楚。5.1 本地能读部署到服务器就读不到这是最高频的问题没有之一。原因几乎都是同一个本地 IDE 运行时资源在target/classes是一个真实目录用new File()能读到打包成 JAR 后资源在压缩包内部File操作全失效。排查方法很简单在服务器上打印一下资源的 URLURL url MyService.class.getClassLoader().getResource(config/app.properties); System.out.println(url);如果输出协议是jar那就基本实锤了。解决办法是改用getInputStream()方式读取而不是去构造File对象。5.2 getResource 返回 null 但文件明明存在这个坑不是路径错的而是“文件压根没被复制到 classpath”。常见原因有两个Maven 配置文件里把 resources 目录过滤得太狠某些类型的文件被排除了IDE 没有把src/main/resources标记为资源目录排查方式很简单看编译输出目录target/classes下有没有目标文件。如果没有先检查pom.xmlbuild resources resource directorysrc/main/resources/directory filteringfalse/filtering includes include**/*.properties/include include**/*.json/include /includes /resource /resources /build如果是 IDE 的问题右键src/main/resources选择Mark Directory as Resources Root重新构建项目就好。5.3 / 前缀到底该不该加这个问题我见过太多次。一张表说清楚调用方式带 / 的语义不带 / 的语义Class.getResource(xxx)classpath 根目录当前类所在包目录ClassLoader.getResource(xxx)非法会返回 nullclasspath 根目录所以Class.getResource建议用/config/app.propertiesClassLoader.getResource一定要用config/app.properties。如果总是混用很容易出现“有时候行有时候不行”的诡异现象。要彻底避免这个困惑最省心的方式就是统一用ClassLoader.getResourceAsStream不带/语义唯一。5.4 中文路径、空格路径和特殊字符问题在 Windows 上尤其容易遇到。因为URL会对路径做编码空格会变成%20中文会变成一长串百分号编码。如果你拿这个编码后的路径去new File()大概率找不到文件。有一个典型报错Caused by: java.io.FileNotFoundException: file:\D:\project\...\config\app%20data.properties解决办法很简单全程不要转 File。读取内容就老老实实用流。如果某个库强制要File使用下面的临时文件方案。5.5 读出来的内容不是最新版本这种事情经常发生在本地调试时你改了src/main/resources下的文件也按了 CtrlS运行时读到的却还是旧内容。原因一般是编译输出目录里的文件没有被重新复制。IDE 的增量编译偶尔对 resources 目录更新不敏感尤其是项目结构比较复杂的时候。解决办法是从源头彻底重建一次mvn clean compile或者在 IDEA 里执行Build - Rebuild Project。部署环境的话确认是重新打的包JAR 里文件也确实是新的。这个“内容旧”的问题本质和代码逻辑没任何关系纯粹是构建时机的问题。5.6 Value 注入 Resource 后使用时报错Value(classpath:config/app.properties)注入Resource时资源不存在并不会在启动时立刻报错因为Resource是惰性加载的只有调用getInputStream()才会真正去访问资源。所以你会看到这样的场景应用启动正常但是某个接口一调就抛FileNotFoundException。这时候不要怀疑 Spring 容器有问题去检查 classpath 下是否有这个文件、路径拼写是否正确即可。如果想在启动时就发现这种问题写一个ApplicationRunner或监听ApplicationReadyEvent启动后主动调用一次读取逻辑做自检把问题前置。5.7 异步线程里读不到资源这个坑比较隐蔽。在 Web 请求线程里读取资源没问题但一旦把任务丢进线程池某些场景下会取不到 classpath 资源。原因多数是线程上下文类加载器TCCL在不同线程里设置的不一致。解决办法是在进入异步逻辑之前把类加载器保存好或者直接用YourClass.class.getClassLoader()这类确定性的类加载器而不是依赖隐式的上下文。ClassLoader cl MyService.class.getClassLoader(); // 提前拿到 executor.submit(() - { try (InputStream is cl.getResourceAsStream(config/app.properties)) { // ... } });5.8 三方库强制要求 File 对象时怎么办有些库接口设计得比较死不接受InputStream只接收File。比如某些 Office 文档处理库、某些报表组件。这时候直接把资源转File在 JAR 包里行不通但我们可以把资源先复制到系统临时目录再返回临时文件public static File resourceToTempFile(String classPath, String suffix) throws IOException { ClassPathResource resource new ClassPathResource(classPath); try (InputStream is resource.getInputStream()) { File tempFile File.createTempFile(app-, suffix); try (OutputStream os new FileOutputStream(tempFile)) { is.transferTo(os); } tempFile.deleteOnExit(); return tempFile; } }这段代码在 JAR 包内外都能正常工作。注意区分File.createTempFile创建的是操作系统临时目录下的真实文件和 classpath 没有关系所以后续用File操作不会受 JAR 包影响。这个方案需要注意临时文件的生命周期用完尽快删除别把临时目录搞得越来越大。5.9 9 种读取方式速查表最后给出速查表方便选择方式核心 APIJAR 包兼容适用场景推荐指数方式一Class.getResourceAsStream高单文件快速读取★★★★★方式二Class.getResource openStream高需要 URL 再做处理★★★★方式三ClassLoader.getResourceAsStream高工具类 / 静态方法★★★★★方式四ClassLoader.getResource openStream高需要 URL 的基础场景★★★★方式五Value Resource高Spring Bean 中读取固定资源★★★★★方式六ResourceLoader Resource高动态拼接路径读取★★★★★方式七ClassPathResource高非 Spring 环境手动读取★★★★方式八PathMatchingResourcePatternResolver高批量读取 / 目录通配符★★★★★方式九Hutool / Guava / Commons IO高追求代码简洁已有依赖★★★★选型的核心建议很简单Spring Bean 内优先Value注入动态路径用ResourceLoader批量读取用ResourcePatternResolver纯 JDK 环境用ClassLoader.getResourceAsStream工具类库则看项目依赖是否已经存在。说个我记忆特别深的教训。有一次同事在本地联调用new File(url.getPath())读文件一切正常日志还专门打了文件绝对路径。结果打包发到测试环境容器里一跑就崩排查到凌晨才发现 URL 的路径里带了一个!/压根不是合法文件。从那以后我给自己定了一条规矩凡是 resources 下的文件一律用流绝不碰 File。这条规矩后来帮我避开了无数次线上事故。今天整理的这 9 种方式希望你至少熟练掌握前三种和后三种再配合最后一张速查表遇到类似问题就能一眼找到最合适的解法。
返回列表