说实话,这标题在 Spring Boot 开发圈里属于"看起来是个小问题,搜上去全是复制粘贴"的类型。我在项目里被这个问题折磨过不止一次,早年在 IDEA 里跑得好好的代码,一打成 jar 包丢到服务器上就报文件找不到,排查了一下午,最后发现是自己压根没搞懂 resources 目录在不同运行环境下的存在形态。这篇东西我打算直接用实际经验来聊,把 Spring Boot 下读取 resources 目录文件的 9 类常见方式掰开揉碎讲清楚,每种方式适配什么场景、有什么坑、为什么这么写,尽量一次说明白,免得大家再去翻那些要么翻译腔严重、要么只贴代码不讲原理的文章。
1. 为什么"读 resources 文件"会成为经典问题
1.1 从一次线上事故说起
之前我带团队做一个数据平台,本地启动一切正常,配置文件、SQL 初始化脚本、模板文件全都能读出来。结果部署到测试服务器上,用java -jar app.jar方式启动,服务起来之后只要触发"读取模板文件"的逻辑就抛FileNotFoundException。当时团队里一个小伙子在代码里是这么写的:
File file = new File("templates/report.html");本地 IDE 运行的时候,工作目录是项目根目录,所以这个相对路径恰好能对上,而打包成 jar 之后,资源文件被压缩到了 jar 包内部,文件系统里根本没有所谓templates/report.html这个路径。这个案例说明,读取 resources 下文件的关键,根本在于得理解资源和运行环境的对应关系,而不是搜索引擎上随便抄一段getResource就完事。
1.2 搞清楚 resources 目录编译后变成了什么
在 Maven 或 Gradle 构建的标准项目里,src/main/resources下面的所有文件,在编译阶段会被原样复制到target/classes(或者build/resources/main),也就是说,java 代码和 resources 文件最终都待在同一个classes目录之下。到了打包环节,这个classes目录又会被整体塞进 jar 包的根路径下。
所以读取 resources 文件,本质上是两种环境下的问题:
- 开发环境:resources 在文件系统里以独立目录存在,路径可以很灵活。
- 部署环境:resources 在 jar 包内部,不能直接当作文件系统路径访问,必须通过
InputStream或者 Spring 的Resource抽象来获取。
明白了这一点,后面所有方式的取舍逻辑就清楚了:凡是直接从文件系统访问的,部署到 jar 包内基本等于做梦;凡是基于classpath抽象访问的,基本都能通吃两种场景。
2. 九种读取方式:各有各的适用场景,别只会一种
这一节里我按"读取思路"分几组来写,前后顺序其实代表了我自己技术选型的优先级。避免那种贴个验证代码就算完事的写法,我尽量把每种方式的底层原理和适配场景讲透。
2.1 基于 Spring 自带的 ClassPathResource 类
Spring Framework 本来就有统一的资源抽象,ClassPathResource就是专门用来定位 classpath 下资源的。这是我在 Spring Boot 项目里最推荐使用的方案,没有之一:
import org.springframework.core.io.ClassPathResource; ClassPathResource resource = new ClassPathResource("template/report.html"); try (InputStream inputStream = resource.getInputStream()) { // 读取内容并处理 String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); System.out.println(content); }为什么推荐?因为它内部自己做了类加载器的兼容处理,对 Tomcat、Spring Boot 的jar包内运行环境适配得最好。此外ClassPathResource还提供了一些辅助方法,比如exists()、isFile()、getFile(),可以帮你判断资源的状态。
但你得注意:别看到getFile()就开心,有些情况下它会抛异常。Spring Boot 打成 fat jar 之后,如果这个资源本身在 jar 内部而你又调用了getFile(),绝大多数情况会抛FileNotFoundException。因为这个方法底层是直接把路径转成java.io.File,jar 包内部的资源根本不是操作系统层级的文件。通用的正确姿势是调getInputStream()打开字节流,然后按需处理。
2.2 注入 ResourceLoader,让 Spring 帮你解析路径
Spring 容器里有一个全局接口叫ResourceLoader,它的getResource(String location)方法可以根据不同的前缀选择不同的资源实现。比如classpath:前缀会得到ClassPathResource,file:前缀会得到FileSystemResource。
@Service public class TemplateService { private final ResourceLoader resourceLoader; public TemplateService(ResourceLoader resourceLoader) { this.resourceLoader = resourceLoader; } public String loadReportTemplate() throws IOException { Resource resource = resourceLoader.getResource("classpath:template/report.html"); try (InputStream inputStream = resource.getInputStream()) { return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); } } }这种方式的优势在于,路径前缀被统一管理,在单元测试里你可以根据需要动态切换资源来源。但要注意默认情况下,不带前缀的路径会被当成文件系统路径处理,而不是 classpath 路径。所以写的时候一定记得加classpath:前缀,不然又会掉进"本地能跑、打包后报错"的坑里。
2.3 通过 ClassLoader 的 getResource 系列方法
这是 Java 原生 API,不属于 Spring 的内容,但它依然是很多人最常用的方式。核心代码大概是:
ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); URL resourceUrl = classLoader.getResource("template/report.html"); if (resourceUrl == null) { throw new IllegalArgumentException("资源不存在"); } try (InputStream inputStream = resourceUrl.openStream()) { // ... }这里有个细节特别容易坑人:ClassLoader.getResource()方法的路径是不能以/开头的。如果你写了/template/report.html,大概率会拿不到,因为类加载器是拿这个字符串直接拼接classpath根路径去检索。另外,如果你用了Class.getResource(),那规则又不一样:
getClass().getResource("/template/report.html") // 从 classpath 根路径开始 getClass().getResource("template/report.html") // 相对于当前 Class 文件所在的包路径我不建议在业务代码里到处写Class.getResource(),因为相对路径太容易让人头晕。相比之下ClassLoader的检索逻辑还算直白,但也要注意多类加载器环境下的不确定性问题,比如某些中间件自己会建类加载器,导致getResource找不到你预期中的文件。如果遇到这种极端情况,建议退回到 Spring 的ClassPathResource方案,兼容性更好。
2.4 用 @Value 注解直接注入一个 Resource
Spring Boot 非常贴心地支持把配置值直接绑定到Resource类型的字段上。写法极其简洁:
@Value("classpath:config/application-custom.yml") private Resource customConfig; @Component public class CustomConfigPrinter { @Value("classpath:data/keywords.txt") private Resource keywordsFile; public void print() throws IOException { try (InputStream in = keywordsFile.getInputStream()) { System.out.println(new String(in.readAllBytes(), StandardCharsets.UTF_8)); } } }我之所以把这种方式单列出来,是因为很多 Spring Boot 开发者不知道还能这么用。它本质和ClassPathResource是同一套底层实现,但优点是把"资源路径"和"业务代码"解耦了,资源位置发生变化时,只需要修改配置文件或注解值,不用改 Java 代码。
注意一点:@Value注入Resource是 Spring 容器启动阶段完成的,如果资源文件指定的路径不存在,启动过程并不会立刻报错,而是在getInputStream()时才抛出异常。这种延迟失败的行为,排查的时候容易误判,建议在容器初始化时主动做一次exists()校验。
2.5 使用 PathMatchingResourcePatternResolver 批量获取
如果我要读取一个目录下所有满足条件的文件,比如templates目录下所有.html模板,或者sql/目录下所有的.sql脚本,ClassPathResource一个个写就太蠢了。这种情况我一般用 Spring 的PathMatchingResourcePatternResolver,它支持 ant 风格的通配符表达式:
import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.core.io.Resource; ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); Resource[] resources = resolver.getResources("classpath:sql/*.sql"); for (Resource resource : resources) { try (InputStream inputStream = resource.getInputStream()) { // 逐个处理 } }通配符支持*、**、?这些写法,比如classpath:config/**/*.yaml就能匹配config下多级目录的 YAML 文件。这种方式在批量执行初始化脚本、加载模板目录、扫描自定义扩展点这类场景下特别实用。
需要留个心眼:PathMatchingResourcePatternResolver通配符匹配在 jar 包内部和文件系统下是两套逻辑。在 IDE 开发环境下,它的实现是直接拼路径然后遍历文件系统,但在 jar 包内,它要走jar://协议遍历归档条目,性能会比文件系统低,而且某些过旧的依赖版本可能存在 jar 包内无法递归匹配的问题。如果你一旦发现批量加载 jar 包内资源时结果为空,优先检查 Spring 版本或框架底层容器的版本冲突。
2.6 核心技巧:FileSystemResource 与外部文件绝对路径
有时候你要读的并不一定位于项目内部 resources 目录,而是部署服务器上配置的某个外置模板目录。这时使用ClassPathResource就不合理了。Spring 的FileSystemResource就是干这个的:
import org.springframework.core.io.FileSystemResource; FileSystemResource resource = new FileSystemResource("/opt/app/templates/report.html"); if (resource.exists()) { try (InputStream inputStream = resource.getInputStream()) { // ... } }同理,ResourceLoader也支持file:前缀,比如:
Resource resource = resourceLoader.getResource("file:/opt/app/templates/report.html");为什么我要把这种方式并列进来?因为在一个正规项目里,"读取 resources 文件"的需求往往会演变成"读取资源配置文件"的需求。比如你把一些大模板、证书文件放在外部磁盘上,只把文件路径配置在 resources 里的application.yml中,这时候的外部文件读取能力就派上了用场。
2.7 把资源读取封装成工具类,一次写好到处用
上面的方式要么依赖 Spring 容器,要么需要写一堆模板代码,用起来其实还不够优雅。我更推荐在项目里自己封装一个小工具类,把"读取 classpath 文件字符串"和"读取 classpath 文件字节数组"这两个高频需求固化下来:
import org.springframework.core.io.ClassPathResource; import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; public final class ResourceReader { private ResourceReader() { } public static String readString(String classpathLocation) { return readString(classpathLocation, StandardCharsets.UTF_8); } public static String readString(String classpathLocation, Charset charset) { return new String(readBytes(classpathLocation), charset); } public static byte[] readBytes(String classpathLocation) { ClassPathResource resource = new ClassPathResource(classpathLocation); if (!resource.exists()) { throw new IllegalArgumentException("classpath 资源不存在: " + classpathLocation); } try (InputStream inputStream = resource.getInputStream()) { return inputStream.readAllBytes(); } catch (Exception e) { throw new IllegalStateException("读取 classpath 资源失败: " + classpathLocation, e); } } }这样业务代码里只需要:
String sql = ResourceReader.readString("sql/init-data.sql");直接把底层细节全隐藏掉。我特别想把这条单独算一种"方式"列出来,是因为工具类不只是简化调用,更重要的是把异常处理、字符集编码、资源存在性校验这些统一在一个地方,避免团队里每个人各写一套,风格混乱。
2.8 使用第三方工具类:Hutool 的 ResourceUtil
如果你的项目里已经引入了 Hutool 这类工具库,那可以直接用它封装的ResourceUtil。Hutool 作为国产工具库,它的ResourceUtil做了很多兼容性处理,底层同时尝试 classloader 和 class 两类加载方式,能处理很多原本需要写 if-else 的场景。
import cn.hutool.core.io.resource.ResourceUtil; String content = ResourceUtil.readUtf8Str("template/report.html");一行代码,完事。它在你传入的路径前自动补全基于 classpath 的查找逻辑,并且返回的对象是cn.hutool.core.io.resource.Resource接口,兼容性不错。
但我不建议为了用这个方式专门引入一套工具库。如果你项目里已经用了 Hutool,用它图个方便无可厚非,但如果项目很干净,完全没有必要为了读一个文件多引入一个依赖。另外,Hutool 终究是第三方封装,遇到它封装的边界条件时,还是得回到 Spring 原生 API 兜底,所以这类工具方法我一般只放在模板代码或一次性脚本里用。
2.9 使用 Java NIO 的 Files 与 Path 结合系统属性
有一些老项目里,你能看到这样的写法:
String rootPath = System.getProperty("user.dir"); Path path = Paths.get(rootPath, "src", "main", "resources", "data", "keywords.txt"); List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);这种方式本质上依赖"当前工作目录 + src/main/resources 相对路径"的组合。在 IDE 里运行确实没问题,但我强烈不建议在正式代码里用。原因很直接:打成 jar 包之后,src/main/resources根本不存在于运行环境中,而且工作目录会因为启动方式不同而完全无法预测。如果你直接照这个写法去部署,九成九要出问题。
那么前面为什么要列出这种写法?它适合的场景是开发期本地调试脚本、单元测试里临时跑数据、或者自动化工具里读取开发目录文件。在这种时候,这种方式反而最直观,不必引入 Spring 上下文,也不受 classpath 约束。简单讲,这个方式是有适用边界的,所以列出来,但请不要把它用在要交付的生产代码里。
3. 读到内容之后,怎么处理才算真正稳妥
文件读取从来不是"拿到InputStream就完事"。真正干活的时候,后续的内容解析、字符集处理、资源释放,每一步都可能有坑。
3.1 字符集问题:不要用 String 默认编码
先看一个最常见的坑:
// 坑爹写法,千万别学 String content = new String(inputStream.readAllBytes());这行代码在新版 JDK 上看起来没毛病,因为 Java 18 之后默认字符集改成了 UTF-8。但在 Java 8/11 的某些服务器环境里,默认字符集取决于操作系统和 JVM 的file.encoding参数,很可能就是 GBK。一旦 resources 里存放的是 UTF-8 的文本文件,你用默认编码转字符串直接就乱码了。
稳妥的写法是显式指定字符集:
String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);如果你读取的是 properties 文件,要注意Properties.load(InputStream)方法默认按 ISO-8859-1 读取。这在 Spring Boot 项目中一般不会踩到,因为 Spring 自己做了转换,但一旦你在工具类里手动load一个带中文的properties,就等着乱码吧。这时候要么文件本身用 ASCII 转义(原生 properties 规范要求),要么别用load,改用其他方式读取。
3.2 读取大文件的姿势
如果是几百 KB 的小配置,直接readAllBytes()没问题,反正内存消耗很小。但要读取一个几百 MB 的模板资源或者要读取一个大数据量的 CSV/Excel,直接把整个文件读进内存就不太理智了。JVM 堆内存本来就金贵,如果并发请求一起来,OutOfMemoryError分分钟教做人。
正确姿势是流式处理:
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { // 逐行处理 } }读取 Excel 时也别直接用WorkbookFactory.create(File),而是使用WorkbookFactory.create(inputStream)方式,或者干脆用 EasyExcel 做流式监听读取。
3.3 怎么把 jar 包内资源转成临时 File
有些第三方库的 API 只认File类型,不接收InputStream。比如某些旧版本的报表引擎、字体解析库、密钥解析工具,你给InputStream它就不认。这时候必须把 classpath 里的资源先落到本地临时目录:
import org.springframework.core.io.ClassPathResource; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; ClassPathResource resource = new ClassPathResource("templates/report.html"); try (InputStream inputStream = resource.getInputStream()) { Path tempFile = Files.createTempFile("report", ".html"); Files.copy(inputStream, tempFile, StandardCopyOption.REPLACE_EXISTING); tempFile.toFile().deleteOnExit(); // 然后把 tempFile 传给只认 File 的库 }这里有个关键点:用完临时文件之后,建议主动删除,别完全指望deleteOnExit(),在长驻进程中deleteOnExit的触发时机并不确定。另外,临时目录可能在每次服务器重启时被清空,但如果你每次都现场创建临时文件,这个问题基本不存在。
3.4 利用 Spring 的 Resource 接口做统一编程
说实话,前面讲的很多方式,说到底都绕不开 Spring 的Resource抽象。如果项目里已经全面使用 Spring Boot,我建议你给团队定一个规范:所有涉及"读取资源文件"的代码,统一以Resource为参数、以InputStream为返回载体。这样可以屏蔽文件系统、jar 包、类路径的差异,也方便以后扩展远程配置中心之类的数据源。
举例来说,定义一个这样的方法签名:
public String resolveTemplateContent(Resource templateResource) throws IOException { try (InputStream inputStream = templateResource.getInputStream()) { return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); } }调用方既可以用ClassPathResource,也可以用FileSystemResource,甚至可以传一个UrlResource,方法内部完全不用改。这种面向接口的设计没什么高深的,但能极大减少将来重构时的痛苦。
4. 实战场景:配置文件、模板文件、SQL 脚本、Excel 文件怎么读
4.1 读取自定义 YAML/JSON/Properties 配置
Spring Boot 读取配置,默认使用@ConfigurationProperties或@Value,但如果你要把某个 JSON 文件读成一个对象,或者把一份额外的 YAML 文件在运行期手动解析,情况就不同了。
下面拿 JSON 举例,用 Jackson 处理:
import cn.hutool.core.io.resource.ResourceUtil; // 如果用了 hutool import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.io.ClassPathResource; ObjectMapper objectMapper = new ObjectMapper(); ClassPathResource resource = new ClassPathResource("config/menu-tree.json"); if (resource.exists()) { try (InputStream inputStream = resource.getInputStream()) { JsonNode rootNode = objectMapper.readTree(inputStream); // 后续操作 } }既然项目是 Spring Boot,直接用注入好的ObjectMapper也行,不必每次new一个。如果你想读取一份自定义 YAML,Spring Boot 支持在启动时把它加载到环境中,但运行期手动解析 YAML 相对麻烦。我建议把这类需求统一迁移到application.yaml里管理,或者使用snakeyaml独立解析,没必要硬塞进 Spring 的配置体系。
4.2 读取 SQL 初始化脚本并自动执行
很多中小型项目,特别是做毕设或内部系统的时候,数据库表结构的初始化脚本会放在src/main/resources/sql/下。应用启动阶段,我们可能想要自动执行这些建表脚本。
一种稳妥做法是使用 Spring 的ScriptUtils:
import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.datasource.init.ScriptUtils; import javax.sql.DataSource; ClassPathResource schemaResource = new ClassPathResource("sql/schema.sql"); try (InputStream inputStream = schemaResource.getInputStream()) { ScriptUtils.executeSqlScript(dataSource.getConnection(), new EncodedResource(schemaResource, "UTF-8")); }如果多个 SQL 脚本,可以配合PathMatchingResourcePatternResolver批量扫描:
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); Resource[] sqlResources = resolver.getResources("classpath:sql/*.sql"); Arrays.sort(sqlResources, Comparator.comparing(Resource::getFilename)); for (Resource sqlResource : sqlResources) { log.info("执行脚本: {}", sqlResource.getFilename()); ScriptUtils.executeSqlScript(dataSource.getConnection(), new EncodedResource(sqlResource, "UTF-8")); }按文件名排序的目的是确保执行顺序可控,比如001_schema.sql、002_data.sql这种命名。另外执行前一定要确保已经获取了数据库连接,并且大事务环境下要小心脚本中途失败导致回滚问题。
4.3 读取模板文件:邮件模板、报表模板
在项目里做邮件服务的时候,邮件正文常常是 HTML 模板。模板文件放在templates/email/下,最简单的方式就是通过ClassPathResource读取模板字符串,然后用模板引擎渲染:
ClassPathResource resource = new ClassPathResource("templates/email/welcome.html"); String templateContent = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);如果你用的是 Thymeleaf 或者 FreeMarker,那不需要手动读取,直接用它们提供的模板解析器从 classpath 加载即可。但如果你只是静态替换几个占位符,不引入模板引擎,上面的写法就很顺手。
在报表场景里,有些框架要求传入模板文件路径,但如果是 jar 包部署就麻烦。我遇到过用 iReport/JasperReports 的项目,Jasper 的JasperFillManager.fillReport可以直接接收一个InputStream,但也有一部分封装只支持文件路径。解决方案仍是先复制到临时目录,再传给框架。
4.4 读取 Excel/CSV 数据文件
热词里出现了"pandas 读取 excel、csv",到 Java 这边,读取 Excel 和 CSV 也是资源读取的高频场景。如果 Excel 文件放在 resources 下,别用File方式去读,一定用InputStream方式加载。
EasyExcel 的示例:
import com.alibaba.excel.EasyExcel; ClassPathResource resource = new ClassPathResource("data/user-info.xlsx"); try (InputStream inputStream = resource.getInputStream()) { List<UserInfo> userInfoList = new ArrayList<>(); EasyExcel.read(inputStream, UserInfo.class, new AnalysisEventListener<UserInfo>() { @Override public void invoke(UserInfo data, AnalysisContext context) { userInfoList.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) { } }).sheet().doRead(); }如果文件很大,记得走监听器模式,不要全部加载进内存。CSV 的话,最简单的方式就是用BufferedReader逐行读,但要注意 CSV 字段转义,建议直接引一个工具库如commons-csv处理,避免手动切分踩到带引号字段的坑。
4.5 读取黑白名单、敏感词文件
像敏感词库、IP 黑名单、地区编码表这类数据,也常被放在 resources 字典目录里。这类文件通常体积不大,可以在应用启动时一次性加载进内存缓存。
我习惯做一个DictionaryService:
@Component public class SensitiveWordService { private Set<String> sensitiveWords = new HashSet<>(); @PostConstruct public void init() throws IOException { ClassPathResource resource = new ClassPathResource("dict/sensitive-words.txt"); try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { String word; while ((word = reader.readLine()) != null) { if (!word.isBlank()) { sensitiveWords.add(word.trim()); } } } } }这里要特别注意:@PostConstruct执行顺序在 Bean 属性注入之后。万一依赖了其他 Bean,而那个 Bean 还没准备好,就会报错。优先级要求高的话,可以改监听ApplicationReadyEvent,或者使用InitializingBean,根据实际需要选。
5. 常见问题与排查技巧实录
5.1 本地能读到,jar 包部署后读不到
这是最经典的一个问题,没有之一。上面我反复强调过根因,这里再总结一个排查思路:
- 第一步,用一个能打印最终路径的方法确认资源到底在哪,比如在代码里临时输出
resource.getURL()或resource.getDescription()。 - 第二步,打开 jar 包查看 resources 文件是否真的被打进去了。命令很简单:
jar tf app.jar | grep "template/report.html"。如果文件没有,那就要去检查 Maven/Gradle 资源配置,比如<resources>配置的 include/exclude 是不是把它过滤了。 - 第三步,确认代码里没有使用
new File("src/main/resources/...")之类的写法。这种写法在 IDE 环境下经常能跑通,但和 jar 包环境完全不兼容。
5.2 ClassPathResource 存在但 getFile 报错
很多人在ClassPathResource调用getFile(),在本地测试环境正常,一到 jar 包内就抛FileNotFoundException,然后懵掉了。原因我已经提过,jar 包内部资源不表示一个真实文件。解决方案是不要依赖getFile()。如果需要 File,自己复制到临时目录。如果你非要在 jar 包里拿到资源对应的 URL,可以用getURL(),但不建议真的拿这个 URL 去构造 File。
5.3 路径开头到底加不加斜杠
这个看似简单的问题也经常让人崩溃。给你一个速查表:
| 方法 | 路径开头是否要加/ | 示例 |
|---|---|---|
ClassLoader.getResource() | 不能加 | getResource("template/report.html") |
Class.getResource() | 加了表示从 classpath 根路径 | getResource("/template/report.html") |
ClassPathResource | 不能加 | new ClassPathResource("template/report.html") |
ResourceLoader.getResource("classpath:xxx") | 前缀固定,后面不加/ | getResource("classpath:template/report.html") |
每次写之前默念一遍:classpath:前缀和资源路径之间不要再加/,类加载器的getResource也同样。加了/的常见后果是返回 null,然后你还要排查半天。
5.4 通配符批量扫描在 jar 包里扫不到文件
如果你使用PathMatchingResourcePatternResolver,在本地开发环境一切正常,但打 jar 包后扫描结果为 0,首先确认 Spring 版本,因为不同版本的 jar 协议处理逻辑有差异。其次检查你写的是不是classpath:前缀,而不是classpath*:。如果你要扫描的路径跨越多个 jar 包,必须使用classpath*:。但如果只是扫描当前 Spring Boot fat jar 内部的资源,classpath:前缀通常已经够了。还有一个冷门坑:某些云原生部署平台使用了特殊的类加载器,对 jar 内递归遍历支持不完整。遇到这种情况,可以退回到构建期把文件列表生成成一个索引。
5.5 IDEA 运行正常,但终端运行时路径变了
IDEA 启动 Spring Boot 时,它的工作目录默认是项目根目录(user.dir),所以很多相对路径能正常工作。而直接在服务器上执行java -jar app.jar时,工作目录取决于你在哪个路径下敲的命令。如果把工作目录和项目目录搞混,基于new File("src/main/resources/...")的代码就会失效。我的建议是:永远不要依赖user.dir来定位项目资源,那是开发期侥幸的做法,不是生产环境能用稳定方案。
5.6 文件名带中文或空格时 URL 编码问题
某些类加载器返回的URL对象,本身会对中文和空格做编码,比如显示成%20、%E4%B8%AD%E6%96%87,如果后续拿这个 URL 去做字符串替换,非常容易出幺蛾子。这时建议直接用inputStream流式读取,不要反复用URL.toString()拼接路径。
6. 工具选型参考与最终推荐
6.1 不同场景下的推荐优先级
下面用我个人的经验做一个排序,方便你快速定位:
| 场景 | 首选方案 | 备选方案 |
|---|---|---|
| Spring Boot 项目常规读文件 | ClassPathResource | ResourceLoader注入 |
| 按通配符批量读取资源 | PathMatchingResourcePatternResolver | ClassPathResource一个个拼接 |
| 配置文件/注解绑定资源 | @Value注入Resource | @ConfigurationProperties |
| 读文本并指定编码 | 工具类封装readString | HutoolResourceUtil.readUtf8Str |
只给第三方库传File | 复制到临时文件 | 重构第三方库调用逻辑,改为InputStream |
| 开发期临时脚本/本地调试 | Files.readAllLines直接读项目路径 | System.getProperty("user.dir") |
6.2 我的经验与建议
所有方式都讲完了,最后还是想给几个实际建议,就当我踩坑之后的总结吧。
第一,写代码之前先确认运行环境。如果项目是 jar 包部署,就不要用任何依赖文件系统路径的方式。统一用InputStream为核心处理好所有资源读取逻辑,能省掉 80% 的部署踩坑时间。
第二,字符集能显式指定就显式指定。文件读取这块,乱码问题一旦出现,排查成本往往比 bug 本身还高。你永远不知道线上服务器的file.encoding是什么,别赌它,直接写StandardCharsets.UTF_8。
第三,小工具类值得维护。我前面给的ResourceReader虽然短,但它把很多细节统一管起来了。一个新同事接手项目,看到ResourceReader.readString("x.json")肯定比看到一堆new ClassPathResource再 try-with-resources 容易理解。
第四,路径命名规范很重要。resources 目录内建议统一用config/、template/、sql/、dict/、data/这类子目录区分用途。时间一长,项目里文件多了,没有规范的话,找文件的时间比写代码的时间还长。
第五,别为了秀操作引入不必要的依赖。有些工具类库确实方便,但我更建议在项目里尽量依赖 Spring 自身的资源抽象,因为它是整个框架体系的基石,不会因为第三方版本升级而带来意外风险。第三方工具可以做补充,但不建议把核心逻辑和它们绑死。