本节目标:能用
Path和Files完成路径拼接、目录创建、读写、复制、移动与属性查询,并知道什么时候必须用临时文件加原子移动,而不是直接覆盖目标文件。
适用版本:JDK 25(对照 JDK 21);java.nio.file从 JDK 7 起就在标准库里。
上一节留下的问题
第 13 章把异常分成了「程序 bug」和「用户可见错误」两类,library 的借阅规则已经能给出清楚的错误信息。但项目还有一个更基础的问题没解决:数据只存在内存里,程序一退出,录入的图书就没了。
最直接的想法是写文件。早期 Java 教程会从 File 开始:
File file = new File("data/books.csv");
if (!file.exists()) {
file.mkdirs();
}
FileWriter writer = new FileWriter(file);
writer.write("isbn,title\n");
writer.close();
这段代码能跑,但几乎每一行都埋着问题:mkdirs() 返回 false 时你不知道是「已经存在」还是「没有权限」;FileWriter 用平台默认编码;close() 抛异常时前面的写入可能丢了;路径用字符串拼,在 Windows 上会把 data/books.csv 当成一个文件名。File 不是不能用了,而是它把太多失败原因压缩成了一个 boolean。
java.nio.file 换了两个抽象:Path 表示路径,Files 表示对路径的操作。 路径是值对象,操作是静态方法,失败时抛 IOException 子类,能带原因。这一节把这两个类型用熟,下一节再处理字节、字符和编码。
Path 是路径,不是文件
Path 是路径的抽象,本身不代表文件存在。创建它不会碰磁盘:
Path data = Path.of("/tmp/java_agent_e/data");
Path file = data.resolve("2026").resolve("10").resolve("books.csv");
resolve 把子路径接在父路径后面。它不检查目录是否存在,也不做字符串拼接,所以不会受分隔符影响。等价的写法是 data.resolve(Path.of("2026", "10", "books.csv")),在 Windows 上会自动使用 \。
本机 JDK 25 上运行下面的探针:
package com.plume.library.io;
import java.nio.file.Path;
public final class PathProbe {
public static void main(String[] args) {
Path data = Path.of("/tmp/java_agent_e/data");
Path file = data.resolve("2026").resolve("10").resolve("books.csv");
System.out.println("file = " + file);
System.out.println("nameCount = " + file.getNameCount());
System.out.println("fileName = " + file.getFileName());
System.out.println("parent = " + file.getParent());
System.out.println("root = " + file.getRoot());
Path messy = Path.of("/tmp/java_agent_e/data/../data/./books.csv");
System.out.println("messy = " + messy);
System.out.println("normalized = " + messy.normalize());
Path relative = data.relativize(file);
System.out.println("relative = " + relative);
System.out.println("resolved = " + data.resolve(relative));
Path portable = Path.of("data", "books.csv");
System.out.println("portable = " + portable);
System.out.println("startsWith = " + file.startsWith(data));
}
}
本机输出:
file = /tmp/java_agent_e/data/2026/10/books.csv
nameCount = 6
fileName = books.csv
parent = /tmp/java_agent_e/data/2026/10
root = /
messy = /tmp/java_agent_e/data/../data/./books.csv
normalized = /tmp/java_agent_e/data/books.csv
relative = 2026/10/books.csv
resolved = /tmp/java_agent_e/data/2026/10/books.csv
portable = data/books.csv
startsWith = true
几个容易忽略的点:
getNameCount()数的是路径段,不是字符。/tmp下的一段是tmp,根/不算。normalize()只做字符串层面的.和..折叠,不访问文件系统,也不解析符号链接。真要拿到真实路径,用toRealPath()。relativize要求两个路径都是绝对路径或都是相对路径,混用会抛IllegalArgumentException。Path.of("data", "books.csv")是跨平台的写法,不要写"data/" + name。
Path 是不可变的,resolve、normalize、toAbsolutePath 都返回新对象。这一点和 String 一样,可以放心传递,不必担心某个方法偷偷改掉调用方的路径。
Files 的读写与检查
Files 是一组静态方法。最常用的读写是 readString / writeString,适合小文件:
Path file = Path.of("/tmp/java_agent_e/data/2026/10/books.csv");
Files.createDirectories(file.getParent());
Files.writeString(file, "isbn,title\n", StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
Files.writeString(file, "9780134685991,Effective Java\n", StandardOpenOption.APPEND);
System.out.println(Files.readString(file));
writeString 不传 OpenOption 时默认是 CREATE、TRUNCATE_EXISTING、WRITE,也就是「不存在就建、存在就清空」。要追加必须显式传 APPEND,否则旧内容会被覆盖。这个默认值坑过很多人:日志写入循环里调用 writeString,最后只剩最后一行。
检查文件时,Files.exists 和 Files.notExists 都可能返回 false:
exists为false可能是文件不存在,也可能是没有权限访问。notExists为false同样不能推出「存在」。- 需要区分时,用
Files.isReadable、Files.isRegularFile,或者直接尝试打开并捕获IOException。
本机探针的其余输出:
exists = true
regular = true
size = 40
content = isbn,title\n9780134685991,Effective Java\n
created = true
modified = true
copy size = 40
copy gone = true
temp name = library-N.txt
时间戳只能判断非空,具体值每次运行不同;上面的临时文件名把随机数字替换成了 N。这些值不重要,重要的是 Files.readAttributes(file, BasicFileAttributes.class) 能一次拿到创建时间、修改时间、大小、是否为目录等属性,而不用像 File 那样多次调用、每次都可能得到不一致的快照。
原子写入:避免半个文件
library 会把图书列表写到 data/books.csv。如果程序在写入过程中崩溃,或者两个进程同时写,直接 Files.writeString(target, ...) 可能留下一个被截断的文件:表头在,数据只写了一半。下次启动读取时,BookCsv.fromLine 会在一行中间抛异常。
解决办法是先写同目录下的临时文件,写完再原子替换目标文件:
private void writeLinesAtomically(List<String> lines) throws IOException {
Path target = file.toAbsolutePath();
Path directory = target.getParent();
Files.createDirectories(directory);
Path temp = Files.createTempFile(directory, target.getFileName().toString(), ".tmp");
try {
try (BufferedWriter writer = Files.newBufferedWriter(
temp, StandardCharsets.UTF_8,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
for (String line : lines) {
writer.write(line);
writer.newLine();
}
}
moveIntoPlace(temp, target);
} finally {
Files.deleteIfExists(temp);
}
}
private static void moveIntoPlace(Path temp, Path target) throws IOException {
try {
Files.move(temp, target,
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
}
三个细节决定了这段代码是否可靠:
- 临时文件必须和目标文件在同一个目录。 跨文件系统的
ATOMIC_MOVE通常会失败,因为原子重命名只在同一个文件系统内有意义。Files.createTempFile(directory, ...)满足这一点。 ATOMIC_MOVE和REPLACE_EXISTING一起用。 只写ATOMIC_MOVE时,目标已存在会抛FileAlreadyExistsException;两个一起写,语义是「原子地替换」。finally里删除临时文件。 移动成功后临时文件已经不存在,deleteIfExists是空操作;移动失败时它负责清理,不留垃圾。
ATOMIC_MOVE 不是所有文件系统都支持,网络挂载、某些容器卷会抛 AtomicMoveNotSupportedException。上面的降级分支换成普通 REPLACE_EXISTING 移动,至少避免出现「目标文件写了一半」,代价是替换瞬间可能短暂不可见。
在 library 里落地
library 的文件存储类只做三件事:读 CSV、写 CSV、把 IOException 留给调用方翻译成用户可读的错误。它不负责业务校验,也不负责决定文件放在哪。把上一节的 writeLinesAtomically 放进同一个类,再加上读写方法:
package com.plume.library.io;
import com.plume.library.domain.Book;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class BookFileStore {
private final Path file;
public BookFileStore(Path file) {
this.file = file;
}
public List<Book> loadCsv() throws IOException {
List<Book> books = new ArrayList<>();
try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line = stripBom(reader.readLine());
if (line == null || !line.equals(BookCsv.HEADER)) {
throw new IOException("CSV 表头不正确: " + line);
}
while ((line = reader.readLine()) != null) {
if (!line.isBlank()) {
books.add(BookCsv.fromLine(line));
}
}
}
return books;
}
public void saveCsv(List<Book> books) throws IOException {
List<String> lines = new ArrayList<>(books.size() + 1);
lines.add(BookCsv.HEADER);
for (Book book : books) {
lines.add(BookCsv.toLine(book));
}
writeLinesAtomically(lines);
}
private static String stripBom(String line) {
if (line != null && !line.isEmpty() && line.charAt(0) == '') {
return line.substring(1);
}
return line;
}
}
调用方只需要给一个路径:
Path dataFile = Path.of("data", "books.csv");
BookFileStore store = new BookFileStore(dataFile);
store.saveCsv(service.allBooks());
本机把三本书写进去再读回来,CSV 内容是:
isbn,title,author,year
9780134685991,"Effective Java, 3rd Edition",Joshua Bloch,2018
9787111213826,深入理解计算机系统,Randal E. Bryant,2016
9780262033848,Introduction to Algorithms,Thomas H. Cormen,2009
注意第一本书的标题里有逗号,被双引号包起来了。CSV 的引号规则不是「看起来整齐」,而是「字段里出现逗号、双引号、换行时必须加引号,内部的双引号写成两个」。BookCsv 和 Csv 负责这件事,存储类不重复实现。
JSON Lines:一行一个对象
CSV 适合表格,但字段一多、类型一变,列顺序就成了隐式契约。JSON Lines(JSONL)换了一种组织方式:每行一个完整的 JSON 对象,行之间用换行分隔。 它比一个大 JSON 数组更容易追加和逐行处理:读一行解析一行,不用把整个文件载入内存。
library 的 JSONL 写入:
public static String toJson(Book book) {
return """
{"isbn":"%s","title":"%s","author":"%s","year":%d}"""
.formatted(
Json.escape(book.isbn()),
Json.escape(book.title()),
Json.escape(book.author()),
book.year());
}
Json.escape 处理双引号、反斜杠、换行和控制字符。书名里出现 " 时,如果直接拼进去,整行 JSON 就坏了。本机写入三本书后,文件内容是:
{"isbn":"9780134685991","title":"Effective Java, 3rd Edition","author":"Joshua Bloch","year":2018}
{"isbn":"9787111213826","title":"深入理解计算机系统","author":"Randal E. Bryant","year":2016}
{"isbn":"9780262033848","title":"Introduction to Algorithms","author":"Thomas H. Cormen","year":2009}
读取时逐行调用 BookJsonl.fromJson。这个解析器只支持「扁平对象、字符串或数字值」,不支持嵌套对象、数组、null、科学计数法。它足够读自己写出的文件,但不足以解析任意 JSON。这也是为什么真实项目通常引入成熟的 JSON 库:手写解析器的边界比想象中多。 卷一不引第三方依赖,所以明确限制格式,并在解析失败时给出带行号的错误。
BookFileStore 的 saveJsonl 和 saveCsv 共用同一个 writeLinesAtomically:临时文件、UTF-8、原子移动。格式不同,落盘策略相同。选 CSV 还是 JSONL 取决于使用方:给 Excel 看用 CSV,给程序读、字段可能增加用 JSONL。
常见错误与边界
用字符串拼路径。 "data/" + name 在 Windows 上可能变成 data/books.csv 一个文件名,取决于后续 API。统一用 Path.of("data").resolve(name)。
把 Files.exists 当权限判断。 目录不可读时它返回 false,你会误判为「文件不存在」,然后创建失败。需要权限信息时用 Files.isReadable / Files.isWritable,或者直接操作并处理异常。
直接覆盖正在被读取的文件。 读者可能读到一半。写入方用临时文件加原子替换;读取方接受「要么旧版本,要么新版本」,不要假设文件永远完整。
跨文件系统 ATOMIC_MOVE。 /tmp 和项目目录可能在不同卷上。临时文件要放在目标目录里,不要用系统临时目录再移动过去。
normalize 当安全检查。 normalize 不会阻止 ../../etc/passwd。如果路径来自用户输入,必须校验它解析后仍在允许的根目录下:root.resolve(input).normalize().startsWith(root),并且注意符号链接要用 toRealPath 再比一次。
Files.readString 读大文件。 它把整个文件读进内存。几百 MB 的日志会直接 OutOfMemoryError。大文件用 Files.lines 或 BufferedReader 逐行处理,下一节会展开。
小结
Path是不可变的路径值对象,resolve、relativize、normalize都不访问文件系统。Files用异常报告失败,比File的boolean返回值多出原因和类型。writeString默认覆盖,追加要显式传APPEND;小文件才适合一次读入内存。- 可靠落盘用「同目录临时文件 +
ATOMIC_MOVE+REPLACE_EXISTING」,失败时清理临时文件。 Files.exists为false不等于不存在,权限问题也会得到false。library的BookFileStore只负责 CSV 读写,路径由调用方决定,异常由上层翻译。
路径和文件操作有了,下一个问题是文件里的内容本身:字节、字符、编码之间到底怎么转换,为什么中文 CSV 到了 Excel 里会乱码。下一节拆开流和字符集。
阅读导航:上一节:13.3 异常转换与用户可见错误 · 下一节:14.2 字节流、字符流与编码 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。