跳到内容

15.2 文件、目录与路径

完成I/O 流与持久化边界后,再用约两小时运行本篇 Java 17 临时仓库实验。目标是能组合、验证和遍历路径,并根据失败恢复与原子性要求选择文件操作。

在仓库里找两张卷轴

你已经能读写战报,也能逐字节复制羊皮地图。老陈把工坊仓库钥匙交给你:“所有精铁卷轴都在某个箱子里。找出 iron- 开头的文本,复制地图,把木料清单移进归档。”

文件内容由流处理;文件系统 API 处理名字、位置、目录结构和元数据。这里最容易犯的错不是少调用一个方法,而是把路径字符串当成已验证的文件、把一次检查当成长期事实,或默认所有文件系统都支持同一种移动语义。

阅读路线

  1. Path 表达位置,用 Files 执行操作;
  2. 区分拼接、词法规范化和真实路径;
  3. 创建、复制、移动、替换和删除;
  4. 关闭并排序惰性目录流;
  5. 读取属性并定义符号链接策略;
  6. 防止简单路径逃逸,同时认识符号链接竞态;
  7. 从变量村进入第二卷复杂度分析。

1. File 仍可用,Path 更能表达现代操作

老代码常见 java.io.File

java
File file = new File("warehouse/scrolls/iron-map.txt");
System.out.println(file.exists());

File 同时承担路径值和部分操作;许多方法只返回 boolean,失败原因不够具体。它没有被移除,也不是看到就必须重写。Swing 文件选择器、旧库和已有接口仍可能返回 File

Java 7 起,新代码通常使用 PathFiles

java
Path path = Path.of("warehouse", "scrolls", "iron-map.txt");
File legacy = path.toFile();
Path modern = legacy.toPath();

Path 只是某个文件系统中的路径值,不要求目标已经存在。Files 方法执行读取、创建、复制和属性查询,并用具体异常报告失败。

2. 组合路径,不拼字符串

java
Path base = Path.of("warehouse");
Path scrolls = base.resolve("scrolls");
Path map = scrolls.resolve("iron-map.txt");

字符串拼接容易写错分隔符,也难处理绝对路径。resolve 遵守当前 FileSystem 的语义。如果右侧是绝对路径,它通常直接返回右侧路径,因此处理外部输入时要额外限制。

常用观察方法:

java
map.getFileName();       // iron-map.txt
map.getParent();         // warehouse/scrolls
map.getNameCount();      // 3
map.isAbsolute();
map.toAbsolutePath();

normalize()toRealPath()

java
Path lexical = Path.of("warehouse/./scrolls/../archive").normalize();
// warehouse/archive

Path real = lexical.toRealPath();
  • normalize() 只按路径语法消除冗余的 . 和可配对的 name/..。它不访问磁盘,也不解析符号链接。
  • toRealPath() 要求目标存在,访问文件系统,并按选项解析符号链接,返回真实绝对路径。

由于符号链接,词法上看似不同的路径可能指向同一文件,词法上位于某目录内的路径也可能通过链接跳出去。选择哪一种取决于你是在整理显示路径,还是在建立安全边界。

相对路径

java
Path root = Path.of("warehouse");
Path file = Path.of("warehouse/scrolls/iron-map.txt");
Path relative = root.relativize(file); // scrolls/iron-map.txt

relativize 要求两个路径来自兼容的 provider,绝对/相对形式也要相容。Windows 上不同盘符通常无法互相 relativize。

3. 创建文件与目录

java
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. 写入、复制、移动与删除

写入

java
Files.writeString(path, "库存:1", StandardCharsets.UTF_8);

默认选项是创建或截断后写入。若需要原子“不存在才创建”:

java
Files.writeString(path, content, StandardCharsets.UTF_8,
        StandardOpenOption.CREATE_NEW,
        StandardOpenOption.WRITE);

复制

java
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

复制目录本身不会递归复制整棵目录树。需要递归复制时,先定义符号链接、属性、覆盖、失败恢复和部分结果清理政策,再用 walkFileTree 实现或选择成熟工具。

COPY_ATTRIBUTES 请求复制部分属性,但具体支持由 provider 决定。POSIX 权限、ACL、扩展属性和创建时间并非在所有文件系统上都存在。

移动

java
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);

同一文件系统内的重命名通常成本较低。跨文件系统移动可能无法直接完成,provider 可以失败,也可能用其他方式实现。

java
try {
    Files.move(temp, target,
            StandardCopyOption.ATOMIC_MOVE,
            StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
    // 只有业务允许非原子替换时才回退
    Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
}

ATOMIC_MOVE 是请求,不是跨平台保证。目标已经存在时的细节也由 provider 契约决定。不能接受中间状态的系统应在目标文件系统上测试实际行为,并明确不支持时是失败还是降级。

删除

java
Files.delete(path);          // 不存在时抛 NoSuchFileException
Files.deleteIfExists(path);  // 不存在时返回 false

非空目录通常抛 DirectoryNotEmptyException。删除符号链接默认删除链接本身,不删除它指向的目标。

5. 完整实验:工坊仓库

程序创建独立临时仓库,写入三张卷轴,筛选精铁文本,复制地图,移动木料清单,并输出排序后的可移植相对路径。目录迭代本身没有固定顺序,所以排序是输出契约的一部分。

java
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);
        }
    }
}

运行:

bash
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"

输出:

text
匹配卷轴:[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. 一层目录:listDirectoryStream

java
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 时:

java
try (DirectoryStream<Path> entries =
        Files.newDirectoryStream(directory, "iron-*.txt")) {
    for (Path entry : entries) {
        System.out.println(entry.getFileName());
    }
}

glob 语法由文件系统 provider 实现,常见模式包括 *.txtiron-*.{txt,md}。目录顺序未指定,要求稳定展示或测试时自行排序。

DirectoryStream 的迭代器通常只能取得一次,也不是并发遍历容器。处理期间目录内容变化时,观察结果依 provider 而定。

7. 递归遍历:walkfindwalkFileTree

Files.walk

java
try (Stream<Path> paths = Files.walk(root)) {
    paths.filter(Files::isRegularFile)
         .forEach(System.out::println);
}

walk 惰性、深度优先,并在 stream 关闭时释放打开的目录。Files.walk(root, 2) 可限制最大深度。

Files.find

java
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:

java
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);
    }
});

可选返回值有 CONTINUESKIP_SUBTREESKIP_SIBLINGSTERMINATE。不要为了“尽量完成”就静默忽略权限错误;备份、删除和索引任务需要不同失败政策。

8. 属性与链接

一次读取一组基本属性:

java
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. 临时文件后替换

直接清空目标再写入时,进程崩溃可能留下空文件或半份内容。常见改进是把完整内容写到目标同目录的临时文件,验证并关闭,再替换目标:

java
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() 不是授权,也不是锁

java
if (!Files.exists(path)) {
    Files.createFile(path);
}

检查和创建之间,另一个线程或进程可以先创建路径。这叫 TOCTOU(检查时与使用时不一致)。使用原子操作并处理结果:

java
try {
    Files.createFile(path);
} catch (FileAlreadyExistsException e) {
    // 按业务规则处理已有目标
}

同理,isRegularFileisWritable 和属性读取都是瞬时观察。它们适合界面提示和分支优化,不应作为之后操作必然成功的证据,更不能代替授权。

11. 外部路径输入与目录逃逸

最基本的词法限制:

java
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是否跨文件系统,业务是否允许非原子回退
FileSystemLoopExceptionFOLLOW_LINKS 后是否出现链接环
列表顺序每次不同目录顺序未指定,需要显式排序
Windows 测试路径不同是否把 Path.toString() 当固定 / 格式
检查 exists 后创建仍失败正常竞态;直接尝试原子操作并处理异常

13. 练习

  1. FileSystemDemo 增加 BasicFileAttributes 输出,但只断言与平台无关的 isRegularFile 和内容大小。
  2. DirectoryStream 实现 iron-*.txt 过滤,再排序结果。比较它与 Files.list 的资源形态。
  3. walkFileTree 复制一个小目录树。写清楚遇到已有目标、权限失败和符号链接时的政策。
  4. 把报告写入改为“临时文件后替换”。分别实现“不支持原子移动就失败”和“允许非原子回退”两种策略。
  5. 扩展 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 现在连成了一套能运行的基础。

老陈没有给你一张“毕业证”,只在仓库门口留下一道题:同样能跑的两段代码,为什么一段处理一万项很快,处理一亿项却突然不可接受?

第二卷从这个问题开始。你将学习怎样描述输入规模、计算步骤和内存增长,而不是凭一次计时判断算法。

进入第二卷:复杂度与 Big-O

Built with VitePress | Software Systems Atlas