15.2 文件、目录与路径
完成I/O 流与持久化边界后,再用约两小时运行本篇 Java 17 临时仓库实验。目标是能组合、验证和遍历路径,并根据失败恢复与原子性要求选择文件操作。
在仓库里找两张卷轴
你已经能读写战报,也能逐字节复制羊皮地图。老陈把工坊仓库钥匙交给你:“所有精铁卷轴都在某个箱子里。找出 iron- 开头的文本,复制地图,把木料清单移进归档。”
文件内容由流处理;文件系统 API 处理名字、位置、目录结构和元数据。这里最容易犯的错不是少调用一个方法,而是把路径字符串当成已验证的文件、把一次检查当成长期事实,或默认所有文件系统都支持同一种移动语义。
阅读路线
- 用
Path表达位置,用Files执行操作; - 区分拼接、词法规范化和真实路径;
- 创建、复制、移动、替换和删除;
- 关闭并排序惰性目录流;
- 读取属性并定义符号链接策略;
- 防止简单路径逃逸,同时认识符号链接竞态;
- 从变量村进入第二卷复杂度分析。
1. File 仍可用,Path 更能表达现代操作
老代码常见 java.io.File:
File file = new File("warehouse/scrolls/iron-map.txt");
System.out.println(file.exists());File 同时承担路径值和部分操作;许多方法只返回 boolean,失败原因不够具体。它没有被移除,也不是看到就必须重写。Swing 文件选择器、旧库和已有接口仍可能返回 File。
Java 7 起,新代码通常使用 Path 与 Files:
Path path = Path.of("warehouse", "scrolls", "iron-map.txt");
File legacy = path.toFile();
Path modern = legacy.toPath();Path 只是某个文件系统中的路径值,不要求目标已经存在。Files 方法执行读取、创建、复制和属性查询,并用具体异常报告失败。
2. 组合路径,不拼字符串
Path base = Path.of("warehouse");
Path scrolls = base.resolve("scrolls");
Path map = scrolls.resolve("iron-map.txt");字符串拼接容易写错分隔符,也难处理绝对路径。resolve 遵守当前 FileSystem 的语义。如果右侧是绝对路径,它通常直接返回右侧路径,因此处理外部输入时要额外限制。
常用观察方法:
map.getFileName(); // iron-map.txt
map.getParent(); // warehouse/scrolls
map.getNameCount(); // 3
map.isAbsolute();
map.toAbsolutePath();normalize() 与 toRealPath()
Path lexical = Path.of("warehouse/./scrolls/../archive").normalize();
// warehouse/archive
Path real = lexical.toRealPath();normalize()只按路径语法消除冗余的.和可配对的name/..。它不访问磁盘,也不解析符号链接。toRealPath()要求目标存在,访问文件系统,并按选项解析符号链接,返回真实绝对路径。
由于符号链接,词法上看似不同的路径可能指向同一文件,词法上位于某目录内的路径也可能通过链接跳出去。选择哪一种取决于你是在整理显示路径,还是在建立安全边界。
相对路径
Path root = Path.of("warehouse");
Path file = Path.of("warehouse/scrolls/iron-map.txt");
Path relative = root.relativize(file); // scrolls/iron-map.txtrelativize 要求两个路径来自兼容的 provider,绝对/相对形式也要相容。Windows 上不同盘符通常无法互相 relativize。
3. 创建文件与目录
Path logs = Files.createDirectories(Path.of("logs", "2026", "07"));
Path today = Files.createFile(logs.resolve("day-16.txt"));| 方法 | 已存在时 |
|---|---|
createDirectory(path) | 抛 FileAlreadyExistsException,父目录必须存在 |
createDirectories(path) | 目标已是目录时正常返回,并创建缺失祖先 |
createFile(path) | 抛 FileAlreadyExistsException |
createTempFile(...) | 原子创建一个新临时文件并返回唯一路径 |
createTempDirectory(...) | 原子创建一个新临时目录 |
createDirectories 若发现同名普通文件,仍会失败。部分祖先可能已经创建后才遇到权限错误,调用者不能假定失败会自动回滚目录树。
4. 写入、复制、移动与删除
写入
Files.writeString(path, "库存:1", StandardCharsets.UTF_8);默认选项是创建或截断后写入。若需要原子“不存在才创建”:
Files.writeString(path, content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE);复制
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);复制目录本身不会递归复制整棵目录树。需要递归复制时,先定义符号链接、属性、覆盖、失败恢复和部分结果清理政策,再用 walkFileTree 实现或选择成熟工具。
COPY_ATTRIBUTES 请求复制部分属性,但具体支持由 provider 决定。POSIX 权限、ACL、扩展属性和创建时间并非在所有文件系统上都存在。
移动
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);同一文件系统内的重命名通常成本较低。跨文件系统移动可能无法直接完成,provider 可以失败,也可能用其他方式实现。
try {
Files.move(temp, target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
// 只有业务允许非原子替换时才回退
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}ATOMIC_MOVE 是请求,不是跨平台保证。目标已经存在时的细节也由 provider 契约决定。不能接受中间状态的系统应在目标文件系统上测试实际行为,并明确不支持时是失败还是降级。
删除
Files.delete(path); // 不存在时抛 NoSuchFileException
Files.deleteIfExists(path); // 不存在时返回 false非空目录通常抛 DirectoryNotEmptyException。删除符号链接默认删除链接本身,不删除它指向的目标。
5. 完整实验:工坊仓库
程序创建独立临时仓库,写入三张卷轴,筛选精铁文本,复制地图,移动木料清单,并输出排序后的可移植相对路径。目录迭代本身没有固定顺序,所以排序是输出契约的一部分。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.List;
import java.util.StringJoiner;
import java.util.stream.Stream;
public class FileSystemDemo {
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("file-demo-");
try {
Path scrolls = Files.createDirectories(root.resolve("warehouse/scrolls"));
Path ironMap = Files.writeString(
scrolls.resolve("iron-map.txt"), "矿脉 A", StandardCharsets.UTF_8);
Files.writeString(
scrolls.resolve("iron-notes.txt"), "精炼温度 1538", StandardCharsets.UTF_8);
Path wood = Files.writeString(
scrolls.resolve("wood.txt"), "木料", StandardCharsets.UTF_8);
List<String> matches;
try (Stream<Path> entries = Files.list(scrolls)) {
matches = entries
.map(path -> path.getFileName().toString())
.filter(name -> name.startsWith("iron-") && name.endsWith(".txt"))
.sorted()
.toList();
}
Path backup = root.resolve("backup.txt");
Files.copy(ironMap, backup, StandardCopyOption.REPLACE_EXISTING);
Path archive = Files.createDirectories(root.resolve("archive"));
Files.move(wood, archive.resolve("wood.txt"));
List<String> tree;
try (Stream<Path> paths = Files.walk(root)) {
tree = paths
.filter(Files::isRegularFile)
.map(root::relativize)
.map(FileSystemDemo::portable)
.sorted()
.toList();
}
boolean identical = Files.mismatch(ironMap, backup) == -1;
check(matches.equals(List.of("iron-map.txt", "iron-notes.txt")), "过滤结果错误");
check(tree.equals(List.of(
"archive/wood.txt",
"backup.txt",
"warehouse/scrolls/iron-map.txt",
"warehouse/scrolls/iron-notes.txt")), "目录树错误");
check(identical, "备份内容不同");
System.out.println("匹配卷轴:" + matches);
System.out.println("目录树:" + tree);
System.out.println("备份一致:" + identical);
System.out.println("普通文件数:" + tree.size());
System.out.println("文件检查通过");
} finally {
deleteTree(root);
}
}
private static String portable(Path path) {
StringJoiner result = new StringJoiner("/");
for (Path part : path) {
result.add(part.toString());
}
return result.toString();
}
private static void deleteTree(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
private static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}运行:
chapter15_files_root=$(mktemp -d)
mkdir -p "$chapter15_files_root/out"
# 将上面的代码保存为 "$chapter15_files_root/FileSystemDemo.java"
javac --release 17 -Xlint:all -Werror \
-d "$chapter15_files_root/out" \
"$chapter15_files_root/FileSystemDemo.java"
java -ea -cp "$chapter15_files_root/out" FileSystemDemo
rm -r -- "$chapter15_files_root"输出:
匹配卷轴:[iron-map.txt, iron-notes.txt]
目录树:[archive/wood.txt, backup.txt, warehouse/scrolls/iron-map.txt, warehouse/scrolls/iron-notes.txt]
备份一致:true
普通文件数:4
文件检查通过portable 不直接依赖 Path.toString() 的平台分隔符,而是逐段用 / 拼接测试输出。业务代码若要生成 URL,不应靠这段辅助方法;使用 URI/URL API 处理转义和 scheme。
清理时先按逆序删除子项,再删除父目录。它只删除程序自己创建的临时树。不要把未经验证的外部目录传给递归删除函数。
6. 一层目录:list 与 DirectoryStream
try (Stream<Path> entries = Files.list(directory)) {
entries.filter(path -> path.getFileName().toString().endsWith(".txt"))
.sorted()
.forEach(System.out::println);
}Files.list 返回惰性 stream,只访问一层。必须关闭。
需要边遍历边处理、避免 stream 管线,或使用 glob 时:
try (DirectoryStream<Path> entries =
Files.newDirectoryStream(directory, "iron-*.txt")) {
for (Path entry : entries) {
System.out.println(entry.getFileName());
}
}glob 语法由文件系统 provider 实现,常见模式包括 *.txt、iron-*.{txt,md}。目录顺序未指定,要求稳定展示或测试时自行排序。
DirectoryStream 的迭代器通常只能取得一次,也不是并发遍历容器。处理期间目录内容变化时,观察结果依 provider 而定。
7. 递归遍历:walk、find 与 walkFileTree
Files.walk
try (Stream<Path> paths = Files.walk(root)) {
paths.filter(Files::isRegularFile)
.forEach(System.out::println);
}walk 惰性、深度优先,并在 stream 关闭时释放打开的目录。Files.walk(root, 2) 可限制最大深度。
Files.find
try (Stream<Path> matches = Files.find(root, 4,
(path, attrs) -> attrs.isRegularFile()
&& path.getFileName().toString().endsWith(".log"))) {
matches.forEach(System.out::println);
}find 把读取到的 BasicFileAttributes 交给谓词,避免谓词再次查询常见属性。
walkFileTree
需要进入/离开目录回调或逐项错误政策时,用 visitor:
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
System.out.println(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException error)
throws IOException {
throw new IOException("遍历失败:" + file.getFileName(), error);
}
});可选返回值有 CONTINUE、SKIP_SUBTREE、SKIP_SIBLINGS 和 TERMINATE。不要为了“尽量完成”就静默忽略权限错误;备份、删除和索引任务需要不同失败政策。
8. 属性与链接
一次读取一组基本属性:
BasicFileAttributes attrs = Files.readAttributes(
path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
attrs.isRegularFile();
attrs.isDirectory();
attrs.isSymbolicLink();
attrs.size();
attrs.lastModifiedTime();
attrs.fileKey();size 对特殊文件未必代表可读字节总数。修改时间精度和可用属性依文件系统而定。
默认遍历不跟随符号链接。启用 FileVisitOption.FOLLOW_LINKS 后,链接可能离开原始树或形成环。Java 会尝试检测循环,并可能抛 FileSystemLoopException。扫描用户可写目录时,先定义是否允许链接;安全敏感操作通常禁用跟随。
POSIX 权限只在支持 PosixFileAttributeView 的 provider 上可用。跨平台程序应查询支持情况,而不是假定 rwx 权限存在。
9. 临时文件后替换
直接清空目标再写入时,进程崩溃可能留下空文件或半份内容。常见改进是把完整内容写到目标同目录的临时文件,验证并关闭,再替换目标:
Path target = Path.of("report.txt").toAbsolutePath().normalize();
Path parent = target.getParent();
Path temp = Files.createTempFile(parent, "report-", ".tmp");
boolean moved = false;
try {
Files.writeString(temp, report, StandardCharsets.UTF_8);
try {
Files.move(temp, target,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}
moved = true;
} finally {
if (!moved) {
Files.deleteIfExists(temp);
}
}这段示例选择了非原子回退。配置中心或事务日志可能必须在不支持原子移动时失败。临时文件放在目标同目录能提高同一文件系统原子移动的机会,也让权限策略更一致。
该模式降低半写文件风险,但不自动保证断电持久性。需要强保证时还要考虑临时文件 force、替换后目录元数据同步和平台文档。
10. exists() 不是授权,也不是锁
if (!Files.exists(path)) {
Files.createFile(path);
}检查和创建之间,另一个线程或进程可以先创建路径。这叫 TOCTOU(检查时与使用时不一致)。使用原子操作并处理结果:
try {
Files.createFile(path);
} catch (FileAlreadyExistsException e) {
// 按业务规则处理已有目标
}同理,isRegularFile、isWritable 和属性读取都是瞬时观察。它们适合界面提示和分支优化,不应作为之后操作必然成功的证据,更不能代替授权。
11. 外部路径输入与目录逃逸
最基本的词法限制:
static Path safeResolve(Path base, String userInput) {
Path normalizedBase = base.toAbsolutePath().normalize();
Path candidate = normalizedBase.resolve(userInput).normalize();
if (!candidate.startsWith(normalizedBase)) {
throw new IllegalArgumentException("path escapes base directory");
}
return candidate;
}方法先把 base 转成绝对规范化路径,因此 ../outside.txt 会被拒绝。本章负向夹具验证这个异常。
这只挡住词法 .. 逃逸。攻击者若能在 base 内创建符号链接,链接可能指向外部;检查后还可能发生重命名或换链竞态。
更稳妥的设计按风险递进:
- 公共 API 接收文件 id,由服务器把 id 映射到自己生成的名字;
- 禁止或明确处理符号链接,并把授权与路径检查分开;
- provider 支持时使用
SecureDirectoryStream,通过已打开目录句柄执行相对操作,减少换链竞态; - 对高价值数据使用操作系统沙箱和最小权限账户。
即使 toRealPath() 表明当前目标位于 base 内,检查后目标仍可能被替换。安全结论必须覆盖“检查到使用”的整个操作。
12. 常见异常与定位
| 异常/现象 | 先检查 |
|---|---|
NoSuchFileException | 目标或中间目录是否存在,当前工作目录是否符合预期 |
FileAlreadyExistsException | 是否使用 CREATE_NEW/createFile,已有目标是什么类型 |
AccessDeniedException | 文件权限、ACL、只读挂载、占用策略 |
DirectoryNotEmptyException | 是否误把普通删除当递归删除 |
AtomicMoveNotSupportedException | 是否跨文件系统,业务是否允许非原子回退 |
FileSystemLoopException | FOLLOW_LINKS 后是否出现链接环 |
| 列表顺序每次不同 | 目录顺序未指定,需要显式排序 |
| Windows 测试路径不同 | 是否把 Path.toString() 当固定 / 格式 |
| 检查 exists 后创建仍失败 | 正常竞态;直接尝试原子操作并处理异常 |
13. 练习
- 给
FileSystemDemo增加BasicFileAttributes输出,但只断言与平台无关的isRegularFile和内容大小。 - 用
DirectoryStream实现iron-*.txt过滤,再排序结果。比较它与Files.list的资源形态。 - 用
walkFileTree复制一个小目录树。写清楚遇到已有目标、权限失败和符号链接时的政策。 - 把报告写入改为“临时文件后替换”。分别实现“不支持原子移动就失败”和“允许非原子回退”两种策略。
- 扩展
safeResolve:只允许[a-zA-Z0-9._-]+文件名,禁止路径分隔符。说明允许列表为什么仍不能代替文件系统权限。
离开仓库前,验证位置而不是字符串
让 FileSystemDemo 在独立临时目录中重复运行,并且只断言排序后的相对路径、普通文件属性和内容。目录枚举顺序、路径分隔符、时间精度和 POSIX 权限都不能被误写成跨平台保证。
再为报告替换准备两种明确策略:要求 ATOMIC_MOVE 的版本在 provider 不支持时直接失败;允许降级的版本记录这次非原子替换。两者都把临时文件放在目标同目录,并在移动失败后清理本次创建的临时项。
最后测试 safeResolve:合法文件名留在绝对规范化后的 base 下,../outside.txt 和外部绝对路径被拒绝。这个词法检查仍挡不住可写目录中的符号链接替换;高风险操作要进一步限制链接、使用目录句柄能力,并依靠操作系统最小权限。
查阅 Java 17 的正式契约
Path:路径组合、规范化、相对化与真实路径。Files:创建、复制、移动、遍历、属性和惰性 Stream 的关闭要求。Files.move:替换与原子移动由 provider 提供的契约。SecureDirectoryStream:通过已打开目录进行相对操作,以降低部分竞争条件风险。
离开变量村
你把精铁卷轴放回编号箱,最后一份报告也已关闭并写入磁盘。变量、控制流、对象、集合、异常、泛型、并发和 I/O 现在连成了一套能运行的基础。
老陈没有给你一张“毕业证”,只在仓库门口留下一道题:同样能跑的两段代码,为什么一段处理一万项很快,处理一亿项却突然不可接受?
第二卷从这个问题开始。你将学习怎样描述输入规模、计算步骤和内存增长,而不是凭一次计时判断算法。