15.2 文件、目录与路径
完成I/O 流与持久化边界后,再用约两小时运行本篇 Java 17 临时仓库实验。目标是能组合、验证和遍历路径,并根据失败恢复与原子性要求选择文件操作。
从一组真实文件操作开始
实验任务很具体:找出名称以 iron- 开头的文本文件,复制一份地图文件,并把材料清单移动到归档目录。每一步都必须说明源路径、目标路径、覆盖策略和失败后的状态。
文件内容由流处理;文件系统 API 处理名字、位置、目录结构和元数据。这里最容易犯的错不是少调用一个方法,而是把路径字符串当成已验证的文件、把一次检查当成长期事实,或默认所有文件系统都支持同一种移动语义。
阅读路线
- 用
Path表达位置,用Files执行操作; - 区分拼接、词法规范化和真实路径;
- 创建、复制、移动、替换和删除;
- 关闭并排序惰性目录流;
- 读取属性并定义符号链接策略;
- 防止简单路径逃逸,同时认识符号链接竞态;
1. File 仍可用,Path 更能表达现代操作
老代码常见 java.io.File:
File file = new File("warehouse/records/iron-map.txt");
System.out.println(file.exists());File 同时承担路径值和部分操作;许多方法只返回 boolean,失败原因不够具体。它没有被移除,也不是看到就必须重写。Swing 文件选择器、旧库和已有接口仍可能返回 File。
Path 与 Files 从 Java 7 提供;这里的 Path.of 从 Java 11 提供,Java 7/8 可用 Paths.get:
Path path = Path.of("warehouse", "records", "iron-map.txt");
File legacy = path.toFile();
Path modern = legacy.toPath();toFile() 只支持默认文件系统的 Path;ZIP 等其他 provider 的路径可能抛 UnsupportedOperationException。Path 只是某个文件系统中的路径值,不要求目标已经存在。Files 方法执行读取、创建、复制和属性查询,并用具体异常报告失败。
2. 组合路径,不拼字符串
Path base = Path.of("warehouse");
Path records = base.resolve("records");
Path map = records.resolve("iron-map.txt");字符串拼接容易写错分隔符,也难处理绝对路径。resolve 遵守当前 FileSystem 的语义。如果右侧是绝对路径,它直接返回右侧路径,因此处理外部输入时要额外限制。
常用观察方法:
map.getFileName(); // iron-map.txt
map.getParent(); // warehouse/records
map.getNameCount(); // 3
map.isAbsolute();
map.toAbsolutePath();normalize() 与 toRealPath()
Path lexical = Path.of("warehouse/./records/../archive").normalize();
// warehouse/archive
Path real = lexical.toRealPath();normalize()只按路径语法消除冗余的.和可配对的name/..。它不访问磁盘,也不解析符号链接。toRealPath()要求目标存在,访问文件系统,并按选项解析符号链接,返回真实绝对路径。
遇到符号链接时,移除 link/.. 甚至可能改变路径原先指向的目标。词法上看似不同的路径也可能指向同一文件,词法上位于某目录内的路径也可能通过链接跳出去。选择哪一种取决于你是在整理显示路径,还是在建立安全边界。
相对路径
Path root = Path.of("warehouse");
Path file = Path.of("warehouse/records/iron-map.txt");
Path relative = root.relativize(file); // records/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 要求原子移动,不支持时会失败;指定它时其他选项都被忽略,包括 REPLACE_EXISTING。目标已存在时,替换还是抛 IOException 由实现决定。不能接受中间状态的系统应在目标文件系统上测试实际行为,并明确不支持时是失败还是降级。
删除
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.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-");
TemporaryTree cleanup = new TemporaryTree(root);
try (cleanup) {
Path records = Files.createDirectories(root.resolve("warehouse/records"));
Path ironMap = Files.writeString(
records.resolve("iron-map.txt"), "batch=A", StandardCharsets.UTF_8);
Files.writeString(
records.resolve("iron-notes.txt"), "quantity=15", StandardCharsets.UTF_8);
Path wood = Files.writeString(
records.resolve("wood.txt"), "wood=8", StandardCharsets.UTF_8);
List<String> matches;
try (Stream<Path> entries = Files.list(records)) {
matches = entries
.filter(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS))
.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(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS))
.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/records/iron-map.txt",
"warehouse/records/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("文件检查通过");
}
}
private static String portable(Path path) {
StringJoiner result = new StringJoiner("/");
for (Path part : path) {
result.add(part.toString());
}
return result.toString();
}
private record TemporaryTree(Path root) implements AutoCloseable {
@Override
public void close() throws IOException {
Files.walkFileTree(root, new java.nio.file.SimpleFileVisitor<>() {
@Override
public java.nio.file.FileVisitResult visitFile(
Path file, java.nio.file.attribute.BasicFileAttributes attrs)
throws IOException {
Files.delete(file);
return java.nio.file.FileVisitResult.CONTINUE;
}
@Override
public java.nio.file.FileVisitResult postVisitDirectory(Path directory, IOException error)
throws IOException {
if (error != null) throw error;
Files.delete(directory);
return java.nio.file.FileVisitResult.CONTINUE;
}
});
}
}
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/records/iron-map.txt, warehouse/records/iron-notes.txt]
备份一致:true
普通文件数:4
文件检查通过portable 不直接依赖 Path.toString() 的平台分隔符,而是逐段用 / 拼接测试输出。业务代码若要生成 URL,不应靠这段辅助方法;使用 URI/URL API 处理转义和 scheme。
TemporaryTree 在关闭时用 visitor 先删文件,再在离开目录时删除目录,避免把整棵树收集到列表。清理失败会由 try-with-resources 保留;它只接收程序自己创建的临时树。不要把未经验证的外部目录传给递归删除函数。
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.iterator() 只能取得一个迭代器,再次调用会抛 IllegalStateException。该迭代器是线程安全、弱一致的,但不会冻结目录;创建后发生的变化可能被看到,也可能看不到。遍历 I/O 错误包装为 DirectoryIteratorException。
7. 递归遍历:walk、find 与 walkFileTree
Files.walk
try (Stream<Path> paths = Files.walk(root)) {
paths.filter(path -> Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS))
.forEach(System.out::println);
}walk 惰性、深度优先,并在 stream 关闭时释放打开的目录。遍历过程中后续 I/O 错误可能包装为 UncheckedIOException,不会全部在调用 Files.walk 时暴露。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 对特殊文件未必代表可读字节总数。修改时间精度和可用属性依文件系统而定。
默认 walk 不递归跟随符号链接,但 Files.isRegularFile(path) 默认会跟随链接查询目标;本页过滤器显式传入 NOFOLLOW_LINKS,排除链接本身。启用 FileVisitOption.FOLLOW_LINKS 后,链接可能离开原始树或形成环。Java 会尝试检测循环,并可能抛 FileSystemLoopException。扫描用户可写目录时,先定义是否允许链接;安全敏感操作通常禁用跟随。
POSIX 权限只在支持 PosixFileAttributeView 的 provider 上可用。跨平台程序应查询支持情况,而不是假定 rwx 权限存在。
9. 临时文件后替换
直接清空目标再写入时,进程崩溃可能留下空文件或半份内容。常见改进是把完整内容写到目标同目录的临时文件,验证并关闭,再替换目标:
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
final class ReportWriter {
private record PendingFile(Path path) implements AutoCloseable {
@Override
public void close() throws IOException {
Files.deleteIfExists(path);
}
}
static boolean replace(Path destination, String report, boolean allowFallback)
throws IOException {
Path target = destination.toAbsolutePath().normalize();
Path parent = target.getParent();
if (parent == null) throw new IllegalArgumentException("Target needs a parent directory");
try (PendingFile temp = new PendingFile(Files.createTempFile(parent, "report-", ".tmp"))) {
Files.writeString(temp.path(), report, StandardCharsets.UTF_8);
try {
Files.move(temp.path(), target, StandardCopyOption.ATOMIC_MOVE);
return true;
} catch (AtomicMoveNotSupportedException error) {
if (!allowFallback) throw error;
Files.move(temp.path(), target, StandardCopyOption.REPLACE_EXISTING);
return false;
}
}
}
}调用 replace(target, text, false) 要求原子移动;传 true 才允许在明确不支持时回退。返回 true 表示原子移动,false 表示发生了非原子回退,调用者应记录这一结果。其他 IOException 直接传播。PendingFile 在移动失败时清理临时项,并让清理异常附加到主异常;非原子移动失败后的目标状态仍可能不确定。
同目录临时文件提高了同一文件系统移动的机会,但它的权限和其他属性不一定等于旧目标。需要保留权限或所有者时,必须另设属性策略;同目录不是权限保证。
该模式降低半写文件风险,但不自动保证断电持久性。需要强保证时还要考虑临时文件 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 转成绝对规范化路径,再使用 Path 组件比较,因此 ../outside.txt 和前缀相似的兄弟目录会被拒绝。它允许解析到 base 自身,也允许 base 内的绝对路径;是否只接受单个文件名需要另一层规则。
下面的独立程序验证正常子路径与两个逃逸输入:
import java.nio.file.Path;
public class PathBoundaryDemo {
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;
}
public static void main(String[] args) {
Path base = Path.of("warehouse").toAbsolutePath().normalize();
if (!safeResolve(base, "record.txt").equals(base.resolve("record.txt"))) {
throw new AssertionError("Valid child rejected");
}
String sibling = base.resolveSibling("warehouse-copy").resolve("record.txt").toString();
for (String input : new String[]{"../outside.txt", sibling}) {
try {
safeResolve(base, input);
throw new AssertionError("Escaping path accepted");
} catch (IllegalArgumentException expected) {
// Expected rejection; this test does not access the filesystem.
}
}
System.out.println("Lexical path checks passed");
}
}Lexical path checks passed这只挡住词法 .. 逃逸。攻击者若能在 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:通过已打开目录进行相对操作,以降低部分竞争条件风险。
如果目录中的文件从十个增加到一百万个,排序、扫描和索引各会增加多少时间与内存?第二卷从这个问题开始。你将学习怎样描述输入规模、计算步骤和内存增长,而不是凭一次计时判断算法。