不少正在做智能视频推荐小程序的开发者都有同感:功能开发到中后期,最琐碎的反而成了“文件处理”这类基础环节。上传视频封面、解析字幕文件、生成分析报告、保存临时结果,每一个动作都绕不开文件操作。如果每个模块都各自封装一套读写逻辑,后期维护轻则重复劳动,重则因为路径分隔符、编码格式、关闭流的问题踩进深坑。今天这篇“第八讲”,就专门把文件工具类拆开细讲,围绕 SpringBoot + Vue + Uniapp + LangChain4j + SpringAI 这套推荐系统项目,把这层通用能力一次性做扎实。
本文的定位是整个系列中的工程基础篇。我会先梳理文件工具类在小程序推荐项目里的职责边界,再从 Windows 与 Linux 路径差异、URL 转义、大小写敏感等场景讲清楚封装动机,然后给出完整的 Java 实现代码,包括文件读取、写入、复制、删除、扩展名判断、安全校验和单元测试。尤其会解释为什么在基于 LangChain4j 与 SpringAI 的管道里,文件工具类往往决定了解析链路是否稳定。
如果正在做视频推荐、AI 内容处理或 Spring Boot 服务端开发,这一篇可以作为通用工具类直接复用。零基础读者也能跟着从零建一个 file-util 模块;有后端经验的开发者可以直接跳到核心代码和避坑清单。
1. 文件工具类在智能推荐小程序中的定位
1.1 为什么需要统一封装文件操作
一个典型的智能视频推荐链路会包含这样的处理流程:视频上传后先生成缩略图和预览图,然后抽取字幕或描述文本,再通过 LangChain4j 调用本地大模型做语义分类,最后把 embedding 向量写入向量库供推荐召回。这个流程里,每个环节都可能产生临时文件、缓存文件和解析结果文件。如果文件读写分散在各个 Service 里,随着业务迭代会逐渐暴露几个问题。
首先是代码冗余。不同模块都写 FileInputStream、BufferedReader、close 这些样板代码,一个项目里出现几十份雷同实现,改一个公共逻辑时需要全量搜索替换。其次是资源管理风险。传统写法如果忘记在 finally 中关闭流,Windows 开发环境可能没问题,但 Linux 生产环境很快就会出现“Too many open files”的报错。再次是路径兼容。开发环境最常见是 Windows,部署环境一般是 CentOS 或者 Docker Linux 容器,File.separator 虽然能解决部分分隔符问题,但还有路径前缀、权限模型、文件名合法性等细节。
统一封装之后,业务代码只需要关心“读字符串”“写字节”“获取扩展名”“判断是否文本文件”这些语义操作,底层细节收敛到一个工具类里。后续如果要把文件存储切换到 OSS、MinIO,只需要替换工具类内部实现,不需要动上层调用。
1.2 在 LangChain4j + SpringAI 链路中的具体作用
智能视频推荐项目中,LangChain4j 和 SpringAI 主要承担文档解析、向量化、对话式搜索和推荐解释。这两种框架都支持从文件源构建 Document 对象,但前提是先把物理文件变成 InputStream 或读取成字符串。这时候文件工具类是接入层的基础设施。
举个实际场景。用户上传一段视频,系统先用 ffmpeg 抽帧得到封面图,再用语音识别得到字幕文件。字幕文件可能是 srt 或 vtt 格式,需要读取为 UTF-8 文本,然后切分成 chunk,交给 LangChain4j 的 EmbeddingModel 生成向量。字幕文件处理失败、编码混乱、路径不存在,都会直接影响后续推荐质量。
再比如 SpringAI 的模型回答如果较长,系统会生成一份 Markdown 格式的推荐理由。这份文件要落盘保存,后续做用户历史推荐记录的附件展示。如果工具类的写入方法没做目录自动创建,就会频繁抛 NoSuchFileException。这些看起来很小的点,串联起来就是项目稳定性的关键。
1.3 一个工具类需要覆盖哪些核心能力
根据推荐小程序的常见需求,我建议把文件工具类划分为四个能力组。
第一个是路径与目录能力:拼接路径、创建父目录、规范化路径、获取项目临时根目录。第二个是读写能力:读取文本、读取字节、写入文本、追加写入、从 InputStream 复制到目标文件。第三个是元数据能力:获取扩展名、判断文件类型、判断文本或二进制、获取文件大小并格式化。第四个是安全与异常能力:防止路径穿越、拒绝非法扩展名、统一转换异常类型、清理临时文件。
这里要注意,工具类不是越全越好。像文件压缩、加密、分片上传这些太重的能力应该放在专门的 FileStorageService 或 OssService 中。工具类保持“薄”和“通用”,职责边界才清晰。
2. 环境准备与项目结构说明
2.1 本次示例的运行环境
本文示例基于 Java 17 + Spring Boot 3.2.x,这是目前 LangChain4j 和 SpringAI 的主流组合。如果项目还在用 Java 8 或者 Spring Boot 2.7,代码思路依然适用,但需要把 jakarta 包名调整回 javax,并且部分 API 需要手动替换。
Maven 是首选构建工具。JDK 使用 17 或 21,推荐用 17,因为 Spring Boot 3 和 LangChain4j 对 17 的支持最成熟。IDE 使用 IntelliJ IDEA,安装 Lombok 插件。操作系统方面,开发机用 Windows 或 macOS 都可以,但建议所有代码都使用 File.separator 或 Paths.get 拼接路径,避免硬编码斜杠。
2.2 项目模块说明
本系列项目的工程结构是这样的:服务端是 SpringBoot 工程,包含 controller、service、mapper、common、config、util 等包;管理后台是 Vue3 + Element Plus;用户端是 Uniapp 小程序。文件工具类属于服务端 common 模块的能力,具体路径为:
src/main/java/com/example/videorecommend/common/util/FileUtils.java为了演示方便,我在本讲创建了一个独立的 boot 工程来承载示例代码,目录结构如下:
file-demo/ ├── pom.xml └── src/main/java/com/example/filedemo/ ├── FileDemoApplication.java ├── controller/ │ └── FileTestController.java ├── service/ │ └── DemoService.java └── common/util/ └── FileUtils.java这里单独拆工程是为了聚焦文件工具类本身的验证,真实项目中直接将 FileUtils 复制到 common/util 即可。
2.3 添加基础依赖
pom.xml 中不需要引入额外的大型依赖,只需要 Spring Boot Starter Web 和 Lombok。如果后续要在真实项目中对接 LangChain4j,再加 langchain4j-spring-boot-starter。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。
3. 核心设计思路与关键技术点
3.1 路径处理的三种方式对比
Java 中拼接文件路径有三种常见方式。第一种是直接使用字符串加 ”/“,例如 String path = "/data/files/" + fileName。这种方式在 Linux 下没问题,在 Windows 下 Java 的 File API 也能识别正斜杠,但输出日志时路径风格不统一,而且一旦与用户输入拼接容易产生冗余斜杠。第二种是 File.separator,能自适应操作系统,但阅读代码时不如直观。第三种是 Paths.get,这是 Java NIO 推荐的方式,既能自适应平台,又能直接生成 Path 对象用于后续 Files 操作。
推荐在文件工具类内部统一使用 Paths.get 和 Path 对象,对外返回字符串时也通过 path.toString()。这样无论操作系统是什么,底层行为都是正确的。示例如下:
Path basePath = Paths.get("/data/video-recommend"); Path targetPath = Paths.get(basePath.toString(), "cover", "20240501.jpg");另外还要处理“当前目录”的相对路径。建议工具类提供 getProjectRootDir 方法,通过 System.getProperty("user.dir") 获取项目运行目录。但注意,生产环境通过 java -jar 启动时,user.dir 是执行命令所在的目录,不一定等于 jar 所在目录。如果要精确定位 jar 路径,应该通过 Location 类来解析,不过对文件工具类这种通用组件来说,user.dir 足够用了。
3.2 资源关闭的两种写法演进
在 Java 7 之前,开发者必须手动在 finally 中关闭 InputStream、OutputStream,代码冗长且容易漏。Java 7 引入 try-with-resources 之后,只要资源实现 AutoCloseable,就能在代码块结束时自动关闭。文件工具类必须一律采用 try-with-resources。
一个常见误区是,有些开发者封装工具方法时在内部手动 close,最后再返回读取结果。这在多数情况下没问题,但一旦中途抛出异常,close 就可能不会执行。try-with-resources 能够保证 close 一定执行,而且如果 try 块和 close 都抛出异常,主异常会优先抛出,其余作为 suppressed 异常记录,方便排查。
3.3 编码不一致导致的乱码问题
智能视频推荐项目中的文本文件来源多样:字幕文件通常是 UTF-8,Windows 记事本保存的 txt 可能是 GBK,VTT 文件带 BOM,而基于 SpringAI 生成的 Markdown 报告则是 UTF-8 无 BOM。如果工具类固定使用默认字符集,就会在 Linux 上偶发乱码,因为 Linux 默认字符集往往是 UTF-8,而 Windows 中文环境默认是 GBK。
所以读取文本的方法必须显式指定 UTF-8,写入也要显式指定 UTF-8。只有做编码探测这样的高级场景,才允许动态判断字符集。还要注意 BufferedReader 读取文件时,readLine 会丢掉换行符,所以读取整个文本时推荐使用 Files.readAllLines 再拼接,Java 8 开始支持。
3.4 安全边界问题
文件工具类最容易被忽视的是安全问题。服务端接收外部传入的文件名、路径参数时,如果直接拼接到目录中,攻击者可以用“../”穿越到任意目录,读取敏感文件或覆盖重要文件。工具类必须提供校验方法:将拼接后的 Path 先 normalize,再判断其 startsWith 指定的根目录。
另外,上传场景通常需要白名单扩展名,比如只允许 jpg、png、gif、mp4、srt、vtt、txt、md。黑名单永远防不住所有情况,白名单更安全。工具类提供 isAllowedExtension 方法,由业务层传入白名单列表,这样工具类本身保持通用,校验规则由业务决定。
4. 完整实战:实现文件工具类
接下来我们从零开始实现一个高可用的 FileUtils。所有代码都基于 Java 17,核心 API 使用 java.nio.file 包。我会把文件按职责拆成多个方法,每一个方法都配上用途说明,方便直接复制到项目里使用。
4.1 创建 Spring Boot 启动类
先创建 FileDemoApplication.java,这个工程只用于演示工具类的运行效果。
// 文件路径:src/main/java/com/example/filedemo/FileDemoApplication.java package com.example.filedemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class FileDemoApplication { public static void main(String[] args) { SpringApplication.run(FileDemoApplication.class, args); } }这是一个标准的 Spring Boot 3 启动类,没有额外配置。工具类不依赖 Spring 容器,因此任何环境都可以直接 new 或调用静态方法。
4.2 定义统一文件异常
为了让调用方能区分“文件不存在”“路径非法”“读取失败”等原因,我建议先定义一个自定义异常类 FileOperationException,继承 RuntimeException。这样做的好处是不强制调用方处理受检异常,但又能通过异常类型快速定位是文件问题。
// 文件路径:src/main/java/com/example/filedemo/common/exception/FileOperationException.java package com.example.filedemo.common.exception; /** * 文件操作统一异常 * 所有文件工具类的IO异常都会转换为该异常抛出 */ public class FileOperationException extends RuntimeException { public FileOperationException(String message) { super(message); } public FileOperationException(String message, Throwable cause) { super(message, cause); } }你可能会问,直接用 IOException 不好吗?我的考虑是:IOException 类型太宽泛,调用方无法一眼判断是权限问题还是路径问题;而且文件工具类内部通常会把 Files 相关的异常包装一次,增加业务可读性。自定义异常还能在 ControllerAdvice 中统一捕获,返回给小程序端友好的错误提示。
4.3 实现路径与目录工具方法
第一个工具方法解决“目录递归创建”的问题。Files.createDirectories 和 Files.createDirectory 的区别是:前者会创建所有不存在的父目录,后者要求父目录已存在。工具类统一用 createDirectories。
// 文件路径:src/main/java/com/example/filedemo/common/util/FileUtils.java package com.example.filedemo.common.util; import com.example.filedemo.common.exception.FileOperationException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; /** * 文件操作工具类 * 提供路径标准化、目录创建、读写复制、删除、类型判断等通用能力 */ public final class FileUtils { private FileUtils() { } /** * 标准化路径并防止目录穿越 * * @param rootPath 根目录 * @param child 子路径 * @return 规范化后的路径 */ public static Path safeResolve(Path rootPath, String child) { Path root = rootPath.toAbsolutePath().normalize(); Path target = root.resolve(child).normalize(); if (!target.startsWith(root)) { throw new FileOperationException("非法路径,不允许访问根目录之外的文件:" + child); } return target; } /** * 创建目录,如果目录已存在则忽略 * * @param dirPath 目录路径 */ public static void createDirectories(Path dirPath) { try { Files.createDirectories(dirPath); } catch (IOException e) { throw new FileOperationException("创建目录失败:" + dirPath, e); } } }safeResolve 是安全路径工具里最关键的方法。它先把 rootPath 和 child 都转成绝对路径后规范化,再用 startsWith 判断最终路径是否在根目录内。这里需要注意 normalize 的重要性,因为 ../ 只有在 normalize 之后才会被解析,直接 startsWith 判断是拦不住穿越的。
4.4 实现文本文件读写
接下来是读写文本,这是智能视频推荐项目中使用频率最高的方法之一。字幕解析、提示词文件读取、推荐结果写入都会调用这里。
/** * 读取文本文件内容,默认UTF-8编码 * * @param filePath 文件路径 * @return 文件文本内容 */ public static String readText(Path filePath) { try { return Files.readString(filePath, StandardCharsets.UTF_8); } catch (IOException e) { throw new FileOperationException("读取文本文件失败:" + filePath, e); } } /** * 写入文本文件,自动创建父目录,默认UTF-8编码 * * @param filePath 文件路径 * @param content 文本内容 */ public static void writeText(Path filePath, String content) { try { Path parent = filePath.toAbsolutePath().getParent(); if (parent != null) { Files.createDirectories(parent); } Files.writeString(filePath, content, StandardCharsets.UTF_8); } catch (IOException e) { throw new FileOperationException("写入文本文件失败:" + filePath, e); } } /** * 追加写入文本,在文件末尾增加内容 * * @param filePath 文件路径 * @param content 追加内容 */ public static void appendText(Path filePath, String content) { try { Path parent = filePath.toAbsolutePath().getParent(); if (parent != null) { Files.createDirectories(parent); } Files.writeString(filePath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { throw new FileOperationException("追加写入文本失败:" + filePath, e); } }这里使用的是 Files.readString 和 Files.writeString,这两个 API 是从 Java 11 开始提供的,语法简洁且内部已经处理好了流关闭。如果你的项目必须兼容 Java 8,可以替换成 new String(Files.readAllBytes(path), StandardCharsets.UTF_8),效果是一样的。
写入方法自动创建父目录这一点很实用。视频封面上传后,无论封面目录是否存在,writeText 或 writeBytes 都能直接落盘,调用方不再需要关心目录初始化。
4.5 实现字节流复制方法
视频封面、预览视频、静态资源经常需要从 InputStream 复制到本地文件。我用 transferTo 方法来实现,这是 Java 9 引入的 InputStream 增强方法,比手动维护 byte 数组更简洁。
/** * 从输入流保存文件 * * @param inputStream 输入流 * @param targetPath 目标文件 * @return 目标文件大小 */ public static long saveInputStream(InputStream inputStream, Path targetPath) { Path parent = targetPath.toAbsolutePath().getParent(); if (parent != null) { Files.createDirectories(parent); } try (InputStream in = inputStream) { return Files.copy(in, targetPath, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new FileOperationException("保存输入流失败:" + targetPath, e); } } /** * 复制文件 * * @param source 源文件 * @param target 目标文件 */ public static void copyFile(Path source, Path target) { Path parent = target.toAbsolutePath().getParent(); if (parent != null) { Files.createDirectories(parent); } try { Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new FileOperationException("复制文件失败:" + source + " -> " + target, e); } }Files.copy 的 REPLACE_EXISTING 参数表示如果目标文件已存在就覆盖。在业务上,生成缩略图时往往需要重试覆盖,所以默认覆盖是合理的。如果某个场景不允许覆盖,可以在方法中增加一个布尔参数,或者由调用方先调用 exists 方法做判断。
4.6 实现文件删除与清理
视频审核不通过、临时上传文件过期、模型分析产生的临时报告不再需要,这些场景都要安全删除文件。删除要区分单文件和目录递归。
/** * 删除单个文件,不存在时忽略 * * @param filePath 文件路径 */ public static void deleteFile(Path filePath) { try { Files.deleteIfExists(filePath); } catch (IOException e) { throw new FileOperationException("删除文件失败:" + filePath, e); } } /** * 递归删除目录及其内容 * * @param dirPath 目录路径 */ public static void deleteDirectory(Path dirPath) { if (!Files.exists(dirPath)) { return; } try { Files.walkFileTree(dirPath, new SimpleFileVisitor<>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { Files.delete(file); return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { Files.delete(dir); return FileVisitResult.CONTINUE; } }); } catch (IOException e) { throw new FileOperationException("递归删除目录失败:" + dirPath, e); } }递归删除用的是 Files.walkFileTree,相比 walk + 手动排序更可靠,因为 postVisitDirectory 能保证目录下的文件先删除,再删除目录本身。这里要特别强调:删除操作不可逆。生产环境中调用 deleteDirectory 前,业务层必须做好权限控制和审计日志,至少记录操作人、路径、触发来源。
4.7 实现扩展名与文件类型判断
视频推荐系统中,判断文件类型有两个作用:一是上传接口的合法性校验,二是根据类型决定走视频处理管道还是文本处理管道。
/** * 获取小写扩展名,带点,如 .jpg * * @param fileName 文件名 * @return 扩展名,无扩展名返回空串 */ public static String getExtension(String fileName) { if (fileName == null || fileName.isBlank()) { return ""; } int index = fileName.lastIndexOf('.'); if (index < 0 || index == fileName.length() - 1) { return ""; } return fileName.substring(index).toLowerCase(); } /** * 判断是否为白名单扩展名 * * @param fileName 文件名 * @param allowedExts 允许的扩展名列表,例如 new String[]{".jpg", ".png"} * @return true表示允许 */ public static boolean isAllowedExtension(String fileName, String[] allowedExts) { String ext = getExtension(fileName); if (ext.isEmpty()) { return false; } for (String allowed : allowedExts) { if (allowed.equalsIgnoreCase(ext)) { return true; } } return false; } /** * 简单的文本类型判断:通过扩展名近似判断 * 常见文本类扩展名包括 txt、md、srt、vtt、json、xml、yaml、csv 等 */ public static boolean isTextFile(String fileName) { String ext = getExtension(fileName); if (ext.isEmpty()) { return false; } return switch (ext) { case ".txt", ".md", ".srt", ".vtt", ".json", ".xml", ".yaml", ".yml", ".csv", ".log" -> true; default -> false; }; }最后一个 isTextFile 方法使用 Java 17 的 switch 表达式。如果项目还在 Java 8,改成传统 switch-case 即可。扩展名判断只做浅层判断,不读取文件头。真正的 MIME 类型检测可以引入 Apache Tika,但对这个工具类来说没有必要,做好扩展名白名单和大小限制就足够应对上传场景了。
4.8 实现文件大小格式化和临时目录
为了方便小程序端展示文件大小,工具类加一个 bytes 转可读字符串的方法。比如视频分析报告 1.2MB、封面图 340KB,这种格式比较友好。
/** * 文件大小格式化:B/KB/MB/GB * * @param size 字节数 * @return 格式化字符串 */ public static String formatSize(long size) { if (size < 1024) { return size + " B"; } double kb = size / 1024.0; if (kb < 1024) { return String.format("%.1f KB", kb); } double mb = kb / 1024.0; if (mb < 1024) { return String.format("%.1f MB", mb); } double gb = mb / 1024.0; return String.format("%.2f GB", gb); } /** * 获取系统临时目录下的子目录 * * @param subDir 子目录名 * @return 子目录路径 */ public static Path getTempSubDir(String subDir) { Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"), "video-recommend"); if (subDir != null && !subDir.isBlank()) { tempDir = tempDir.resolve(subDir); } createDirectories(tempDir); return tempDir; } }这样整个 FileUtils 类就完整了。目前共包含 12 个工具方法,基本覆盖智能视频推荐服务端的日常文件处理需求。
5. 编写测试接口与完整示例运行
5.1 创建测试 Service
为了让代码可以实际运行,我创建一个 DemoService 来模拟“接收字幕文件并生成分析报告”的场景。
// 文件路径:src/main/java/com/example/filedemo/service/DemoService.java package com.example.filedemo.service; import com.example.filedemo.common.util.FileUtils; import org.springframework.stereotype.Service; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @Service public class DemoService { /** * 模拟处理字幕文件并写入推荐分析报告 * * @param subtitleContent 字幕文本内容 * @return 报告保存路径 */ public String processSubtitle(String subtitleContent) { String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); Path baseDir = Paths.get(System.getProperty("user.dir"), "data", date); // 1. 保存原始字幕文件 Path subtitlePath = FileUtils.safeResolve(baseDir.toAbsolutePath(), "subtitle.srt"); FileUtils.writeText(subtitlePath, subtitleContent); // 2. 模拟推荐引擎处理,生成一段报告 String report = "视频推荐分析报告\n" + "处理时间:" + LocalDateTime.now() + "\n" + "字幕长度:" + subtitleContent.length() + " 字符\n" + "推荐标签:#科技 #知识科普\n"; // 3. 写入报告文件 Path reportPath = FileUtils.safeResolve(baseDir.toAbsolutePath(), "report.md"); FileUtils.writeText(reportPath, report); return reportPath.toString(); } }这个 Service 演示了工具类的组合用法。safeResolve 保证只在 data 目录下生成文件,writeText 自动创建日期目录,调用方不需要关心目录存在性。
5.2 创建测试 Controller
我们再创建一个 REST 接口来触发测试,方便用浏览器或 Postman 观察结果。
// 文件路径:src/main/java/com/example/filedemo/controller/FileTestController.java package com.example.filedemo.controller; import com.example.filedemo.service.DemoService; import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; @RestController @RequestMapping("/api/file-test") public class FileTestController { private final DemoService demoService; public FileTestController(DemoService demoService) { this.demoService = demoService; } @PostMapping("/process") public Map<String, Object> process(@RequestParam String content) { String reportPath = demoService.processSubtitle(content); Map<String, Object> result = new HashMap<>(); result.put("code", 200); result.put("message", "处理成功"); result.put("reportPath", reportPath); return result; } }5.3 启动与验证过程
启动 Spring Boot 应用后,使用 curl 模拟请求:
curl -X POST "http://localhost:8080/api/file-test/process" \ -d "content=hello world, this is a video subtitle" \ -H "Content-Type: application/x-www-form-urlencoded"预期响应:
{ "code": 200, "message": "处理成功", "reportPath": "/Users/yourname/projects/file-demo/data/20240501/report.md" }此时项目根目录下会自动生成 data/20240501 目录,里面包含 subtitle.srt 和 report.md 两个文件。报告文件内容与 Service 中拼接的内容一致。这一步验证了 writeText 的目录自动创建能力以及 safeResolve 的正常路径拼接。
5.4 验证路径穿越拦截
接下来我们故意传入带 ../ 的文件名,测试 safeResolve 能否拦截。
Path safePath = FileUtils.safeResolve( Paths.get("/data/video-recommend"), "../../etc/passwd");运行这段代码会抛出 FileOperationException,提示“非法路径,不允许访问根目录之外的文件”。这就是安全的底线。真实项目中,文件名来自外层上传请求,绝不能信任客户端传入的路径,必须经过 safeResolve 或同等逻辑。
6. 常见问题与排查清单
在文件工具类接入项目的过程中,开发伙伴们遇到的问题高度集中在下面几张场景表里。我把现象、根因和解决思路整理在一起,可以按表格快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 读取文件乱码 | 文件本身是 GBK 或带 BOM,工具类强制 UTF-8 解码 | 显式转码读取;带 BOM 的 UTF-8 文件先去除 BOM 或用 InputStreamReader + CharsetDetector |
| 上传封面后目录不存在 | 未调用 createDirectories 或写文本时未自动创建父目录 | 确认使用 FileUtils.writeText 或 saveInputStream,两个方法内部都会创建父目录 |
| Linux 上路径多出反斜杠 | 代码中使用了 “\” 拼接路径 | 改用 Paths.get 或 File.separator;日志显示统一用 Path.toString |
| 删除临时目录报 DirectoryNotEmptyException | 目录中还有隐藏文件或文件夹 | 递归删除时使用 walkFileTree 而不是直接 delete |
| 中文文件名存储后乱码 | URL 编码未解码,数据库存储编码不一致 | Controller 接收时显式 URLDecoder.decode;数据库连接串加 characterEncoding=utf8 |
| 文件明明存在但读取报 NoSuchFileException | 相对路径基准目录和当前工作目录不同 | 使用绝对路径,或基于 user.dir 构建稳定路径 |
| 大文件读取导致内存溢出 | 直接 Files.readAllBytes 读取超大文件 | 改用 BufferedReader 逐行处理,或使用 Files.lines 配合 stream |
| 扩展名判断失败 | 文件名最后一段是目录名,或文件名本身就是隐藏文件 | 先 Files.isRegularFile 判断,再获取扩展名 |
| 上传接口被传 ../../ 路径 | 前端拼路径或恶意构造 | 服务端用 safeResolve 强制约束根目录,统一抛业务异常 |
| 日志出现 Too many open files | InputStream 未关闭 | 检查所有自定义 IO 是否使用 try-with-resources |
排查清单如下:先看异常类型是不是 FileOperationException,如果是,堆栈会直接指出是创建、读取、写入还是删除哪个环节失败。再看路径打印值是否在预期根目录内,排除路径拼接错误。然后确认文件编码是否 UTF-8,特别是字幕文件。最后检查磁盘权限和剩余空间,Docker 容器还要看挂载卷是否可写。
7. 在 LangChain4j 与 SpringAI 项目中的落地示例
7.1 LangChain4j 读取字幕文件组装 Document
在智能视频推荐项目中,LangChain4j 常用于把字幕文本构建为 Document 并切分向量化。文件工具类在这里的价值是把“读取物理文件”封装干净,让业务代码专注文档切分逻辑。
// 核心思路示例 Path subtitlePath = FileUtils.safeResolve(baseDir, "subtitle.srt"); String subtitleText = FileUtils.readText(subtitlePath); Document document = Document.from(subtitleText, Metadata.from(Map.of( "source", "subtitle", "videoId", videoId, "createTime", LocalDateTime.now().toString() ))); TextSegmentizer segmentizer = new ParagraphSegmentizer(500, 50); List<TextSegment> segments = segmentizer.segment(document);这段代码的思路是:先通过 FileUtils 读入字幕文本,再包装成 LangChain4j 的 Document,最后切分。Metadata 中可以附加视频 ID、标签等业务字段,后续检索时能作为过滤条件使用。
7.2 SpringAI 生成 Markdown 推荐报告并落盘
SpringAI 的 ChatModel 返回的内容通常是一段流畅的推荐语。为了保留历史,方便小程序端随时翻阅,服务端会把它封装为 Markdown 报告文件。
// 核心思路示例 String recommendation = chatModel.call("请推荐3部关于人工智能的纪录片并说明理由"); String markdown = "# 智能推荐报告\n\n" + recommendation; Path reportFile = FileUtils.safeResolve(reportRoot, videoId + "_report.md"); FileUtils.writeText(reportFile, markdown); // 返回给小程序端的是可访问的相对路径或 URL return "/report/" + videoId + "_report.md";这样 SpringAI 输出的内容就与文件系统解耦了。后续想看历史推荐,只需要读取对应 report 文件即可。写入之前同样调用 safeResolve,防止视频 ID 中混入特殊字符造成目录穿越。
7.3 批量清理过期临时文件
智能推荐系统每天会产生大量临时视频帧、音频切片和爬虫缓存。这些文件如果长期不清理,磁盘占用会越来越大。工具类的 deleteDirectory 可以配合 Spring 定时任务完成自动清理。
// 核心思路示例 @Scheduled(cron = "0 0 2 * * ?") public void cleanExpiredTempFiles() { Path tempBase = FileUtils.getTempSubDir("frames"); try (Stream<Path> paths = Files.list(tempBase)) { paths.forEach(path -> { try { BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); long modifiedTime = attrs.lastModifiedTime().toMillis(); if (System.currentTimeMillis() - modifiedTime > 24 * 60 * 60 * 1000L) { FileUtils.deleteDirectory(path); log.info("清理过期临时文件: {}", path); } } catch (IOException e) { log.error("读取文件属性失败: {}", path, e); } }); } catch (IOException e) { log.error("清理临时目录失败", e); } }这里需要注意的是,定时任务扫描目录时如果目录不存在,Files.list 会抛异常。建议先调用 Files.exists 判断目录是否存在再扫描。生产环境还可以把临时目录配置为独立磁盘分区,避免 /tmp 分区写满导致系统异常。
8. 最佳实践与工程建议
8.1 工具类设计规范
文件工具类必须是 final 类,构造方法私有化,所有方法静态化。这样可以避免子类继承破坏实现细节,也让调用方式统一。类命名建议统一为 FileUtils,不过度拆分,方法不宜超过 20 个。
所有方法都接收 Path 而不是 String,这样可以避免调用方频繁进行字符串转 Path,也能利用 Path 自带的 normalize 和 resolve 语义。对外返回类型同理,返回 Path 而不是 String。如果 Controller 层需要给前端返回字符串,在 Controller 层转换即可。
统一编码是硬性要求。工具类内部所有读写字符串的地方都应使用 StandardCharsets.UTF_8,不支持动态字符集参数,除非方法名为 readTextAsGBK 这样明确的专用方法。统一编码能减少大量排查乱码的时间。
8.2 异常与日志规范
所有 IO 异常都应包装为 FileOperationException 抛出。包装时保留原始异常 cause,这样在日志链路中才能完整看到底层是 FileNotFoundException、AccessDeniedException 还是 DiskQuotaExceededException。
工具类内部不打印日志,日志由调用方按业务场景输出。因为工具类是静态方法,被高频调用,内部打印日志会产生大量冗余日志,而且调用方自己更清楚该用什么业务关键字关联日志。
8.3 安全性底线
任何来自前端的路径都不能直接拼接。统一经过 safeResolve 校验,并且在 Controller 入口做参数校验。上传文件名建议由后端重新生成 UUID 文件名,而不是使用客户端传入的原文件名。原文件名可以存数据库,但磁盘文件名用 UUID 加扩展名。
扩展名白名单必须由业务层定义,工具类只提供判断能力。比如封面只允许 jpg、png、webp,字幕只允许 srt、vtt。要对文件大小做限制,Spring Boot 的 spring.servlet.multipart.max-file-size 可以配置,工具类也可以提供 size 校验方法。
8.4 性能与可维护性
高频读写不要用同步锁,FileUtils 的方法本身是线程安全的,Files API 内部没有共享可变状态。如果担心多线程并发写同一个文件,那属于业务层需要处理的问题,工具类保持无状态即可。
大量小文件读写时,可以考虑使用 Files.newBufferedWriter 配合批量写入,而不是每条内容都调用一次 writeString。比如字幕切分后写分块文件,使用一个 Writer 循环写入即可。
路径处理不要在自己代码里硬编码正则替换反斜杠。Path.toString 在 Windows 平台会输出反斜杠,Linux 输出正斜杠,前端拿到路径后如果要做裁剪展示,应该在前端处理,服务端不要试图统一替换。
9. 总结与下一步学习方向
这一讲围绕智能视频推荐小程序服务端的基础文件能力做了完整梳理。从 FileUtils 的设计动机、路径安全、编码问题到实际落地代码,包含了读取文本、写入文本、复制流、递归删除、扩展名判断、大小格式化等一系列工具方法。配合 LangChain4j 和 SpringAI 的链路,文件工具类相当于一条稳定的传送带,让字幕解析、报告落盘、临时清理都能顺畅运转。
下一步可以关注的进阶方向有几个:一是文件存储从本地磁盘迁移到 MinIO 或 OSS,FileUtils 内部可以增加一个 StorageType 开关,让上层无感知切换;二是引入 Apache Tika 做 MIME 类型嗅探,弥补扩展名判断的不足;三是把文件名生成、路径规范、上传校验封装成一个更上层的 FileStorageService,让 Controller 和 Service 不再直接调用 FileUtils。
如果后续继续更新这个系列,我计划在下一讲中详细展开 Spring Boot 如何接收小程序端上传的视频和封面文件,包括 MultipartFile 与 FileUtils 的衔接、大文件分片上传、进度返回,以及和 LangChain4j 文档解析管道的串联。
希望这篇文件工具类的实现能够派上用场。你可以把它直接复制进项目改造使用,也可以按照自己的业务再做裁剪。如果遇到某个方法不满足特殊场景,欢迎在评论区交流具体的业务场景,后续可以补充更多文件处理专题。