← 返回 Java 后端知识路线
阶段 02集合与并发

File 与 IO:把文件当作资源,沿着打开到关闭完整走一遍

使用 Path、Files 和 BufferedReader 写一个日志摘要器,理解字符编码、资源生命周期和异常边界。

第 11 / 35 篇
PathFilesIOUTF-8

先看这一课值不值得学

学完后,你手里多了哪些代码积木

把文件读写当成有生命周期、可能失败的资源操作。

本课正式新增

语法 / API / 命令你必须会到什么程度
Path / Files描述路径并执行现代文件操作
InputStream / Reader读取字节或字符
try (resource) { ... }作用域结束时自动关闭资源

本课只借用,先别硬背

  • 序列化格式只比较边界,网络传输在第 15 课

学完必须能独立写

  • 读取日志生成摘要文件
  • 正确选择编码并保证资源关闭
本课目录
  1. 1. 现实问题:读文件成功不等于程序可靠
  2. 2. 最小可运行示例:逐行读,统一关资源
  3. 3. 调用链与对象变化
  4. 4. 为什么这样设计
  5. 5. 项目落点:导入任务的输入、处理、结果三段式
  6. 6. 易错排查
  7. 7. 一页复习

1. 现实问题:读文件成功不等于程序可靠

后台任务经常读取导入文件、扫描日志或生成导出结果。路径可能不存在,文件可能很大,编码可能不是默认编码,程序也可能在读到一半时抛异常。若用一个 readAllLines 解决所有场景,短文件没问题,生产数据就可能一次性占满内存。

NIO.2 的 Path 表达位置,Files 提供读写操作,Reader/Writer 决定字符流处理方式。今天做一个按行统计错误日志的工具:使用 UTF-8、逐行处理、输出摘要,并在异常时保留路径。

2. 最小可运行示例:逐行读,统一关资源

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class LogSummary {
    public static void main(String[] args) throws IOException {
        Path input = Path.of("app.log");
        Path output = Path.of("error-summary.txt");
        int errors = 0;
        try (BufferedReader reader = Files.newBufferedReader(input, StandardCharsets.UTF_8);
             BufferedWriter writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.contains("ERROR")) {
                    writer.write(line);
                    writer.newLine();
                    errors++;
                }
            }
        }
        System.out.println("错误行数=" + errors);
    }
}

newBufferedReader 不会把全部文件加载进内存,缓冲层减少系统调用;StandardCharsets.UTF_8 让行为不依赖机器默认编码。throws IOException 适合示例入口,业务方法则应根据边界翻译为可处理的领域异常。

3. 调用链与对象变化

Path.of 创建不可变路径值,Files.newBufferedReader 打开底层文件句柄并包装成 Reader,再由 BufferedReader 管理字符缓冲。每次 readLine 返回新的 String 或 null;contains 只读内容,Writer 把匹配行编码成 UTF-8 字节并写入目标文件。

离开 try 块时,编译器会生成等价的 finally 关闭逻辑,即使循环中抛 IOException 也会关闭两个资源。关闭顺序与声明相反,BufferedReader 先关闭内部 Reader,Writer 关闭并刷新缓冲;若 close 也失败,Java 会把它记录为 suppressed exception,主异常仍然保留。

4. 为什么这样设计

Path 把路径表达与文件操作分开,方便测试和替换临时目录;BufferedReader 让“按行”成为明确的读取策略;字符集显式写出,避免中文文件在不同系统乱码。小文件可以 Files.readString,大文件或流式处理应使用 BufferedReader 或 Files.lines,并保证流也被关闭。

文件系统是外部边界,目录存在、权限、软链接和竞态都不能靠类型系统保证。检查存在再写入不是原子保证,关键文件应使用合适的创建选项、临时文件和原子移动。路径来自用户时要限制根目录并拒绝 .. 穿越。

5. 项目落点:导入任务的输入、处理、结果三段式

导入接口先把上传文件保存到受控临时目录,再由任务服务逐行解析,最后将成功与失败记录分别写入结果。不要让 Controller 直接读服务器任意路径。路径、文件名、编码和大小都应在边界校验,日志记录文件 id 而不是完整敏感内容。

练习:把工具改成统计 INFO、WARN、ERROR 三类,并对超过 10MB 的输入使用流式处理;再增加一个测试,写入中文临时文件后读取并断言摘要。测试结束删除临时资源,验证文件不存在和无权限时的错误信息能定位实际路径。

6. 易错排查

  • Windows 和 Linux 路径拼接:使用 Path.resolvePath.of,不要手拼反斜杠。
  • 中文乱码:读写两端显式使用同一 Charset,别依赖系统默认编码。
  • 文件被占用或内容不完整:确认 Writer 已关闭/刷新,任务状态不要在写入完成前标记成功。
  • 资源泄漏:Reader、Writer、InputStream、数据库连接都放进 try-with-resources。

7. 一页复习

Path 表达位置,Files 选择操作,Reader/Writer 处理字符,Charset 说明编码,try-with-resources 管生命周期。大文件优先流式处理,外部路径必须校验,成功状态要晚于资源关闭和结果落盘。文件 API 的调用链和 JDBC 资源链具有同样的“获取—使用—关闭”结构。

文件读取的状态可以按四步观察:Path 只描述位置,还没有打开句柄;newBufferedReader 成功后拥有输入资源;每次 readLine 返回一行或 null,行内容被转成错误摘要;离开 try 后句柄关闭,输出文件才应被宣布完成。若中途异常,输出可能只有半个文件,所以任务状态必须标记为失败并清理临时文件,而不是复用旧摘要。

路径安全要放在存储边界:用户给出的文件名只用于显示,服务端生成 key;resolve 后要 normalize,再确认最终路径仍位于允许根目录内。读取配置文件时固定 Charset,写入时先写临时文件再原子移动,可以避免进程崩溃留下一个看起来完整但实际截断的导出文件。权限和软链接需要在 Linux 运行环境单独验证。

项目中 FileImportService 可以只接收 InputStream 和业务文件 id,LocalFileStorage 负责 Path/Files,ImportParser 负责逐行解析,Controller 不接触服务器路径。这样未来换对象存储时,解析和校验不需要改。日志记录文件 id、大小、耗时和失败行号,原始正文只在受控调试环境保留。

排错时先看文件是否存在和最终绝对路径,再看编码和大小,最后看解析逻辑。中文乱码是字符集不一致,不是 String 处理错误;文件突然为空可能是 writer 尚未关闭、同名并发覆盖或临时移动失败;任务内存飙升则检查是否调用 readAllLines 或把每行都保留在列表。练习是限制输入 10 MB、统计三类日志并验证超限文件不会创建最终结果。

文件输入输出还可以分成内容状态和资源状态两张表:内容从字节按 UTF-8 解码为行,再从行变成统计结果;资源从未打开变成已借用,最后必须归还。读到一半失败时,业务结果应为失败且目标文件不应被当作完整文件。文件大小、最后修改时间和 hash 可作为导入证据,但不能代替格式校验。

如果项目使用上传文件,Controller 只拿到 InputStream,FileStorage 生成安全 key 并负责落盘,LogParser 逐行返回错误位置。部署到 Linux 后,权限用户、挂载卷、磁盘空间和时区都可能与本机不同,CI 应在临时目录跑一遍中文、空文件和大文件测试。Path 的绝对化只用于诊断,不要把机器路径返回给客户端。

排错时路径不存在先查工作目录和配置来源,乱码再查两端 Charset,文件被占用再查资源是否关闭和并发写入,结果截断再查临时文件移动和进程中止。若文件内容包含敏感数据,错误日志只能记录文件 id 与行号,不能为了方便把整行打印出来。练习是让失败导入留下可清理的临时目录,并由 finally 或补偿任务删除。

文件任务的成功标志应晚于 writer 关闭和临时文件移动,失败标志应包含路径摘要、行号和清理结果。这样重启任务时可以区分未开始、部分处理和已完成,而不是盲目覆盖旧文件。

验证清单:先导入 UTF-8 中文空文件,再导入带坏行、刚好 10 MB 和超过限制的文件,分别记录读取行数、失败行号、临时文件状态和最终结果路径。中途让解析器抛异常,确认目标文件没有被标记为成功;重复执行同一个文件 id,确认 key 或幂等规则不会把一次导入写成两份。复盘时画出 Path、Reader、解析结果和临时文件的生命周期,任何提前返回都必须经过资源关闭和清理策略。若日志出现整行原文,说明脱敏边界被破坏。

进阶附录:文件监听与安全边界

WatchService 适合监听目录变化,但事件可能合并、丢失或重复,消费端要重新读取文件并做幂等。外部上传文件要防止路径穿越、压缩炸弹和符号链接绕过目录检查;这类安全策略不能只靠 normalize() 一行代码。

本课按「Java 21 NIO.2、文件路径与字符流」的学习范围组织,正文与示例均为本站原创整理。