跳到内容

第 1 卷 / 第 15 章 / 第 2 课

15.2 文件、目录与路径 ​

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

从一组真实文件操作开始 ​

实验任务很具体:找出名称以 iron- 开头的文本文件,复制一份地图文件,并把材料清单移动到归档目录。每一步都必须说明源路径、目标路径、覆盖策略和失败后的状态。

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

阅读路线 ​

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

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

老代码常见 java.io.File:

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

java
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. 组合路径,不拼字符串 ​

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

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

常用观察方法:

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

normalize() 与 toRealPath() ​

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

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

遇到符号链接时,移除 link/.. 甚至可能改变路径原先指向的目标。词法上看似不同的路径也可能指向同一文件,词法上位于某目录内的路径也可能通过链接跳出去。选择哪一种取决于你是在整理显示路径,还是在建立安全边界。

相对路径 ​

java
Path root = Path.of("warehouse");
Path file = Path.of("warehouse/records/iron-map.txt");
Path relative = root.relativize(file); // records/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 要求原子移动,不支持时会失败;指定它时其他选项都被忽略,包括 REPLACE_EXISTING。目标已存在时,替换还是抛 IOException 由实现决定。不能接受中间状态的系统应在目标文件系统上测试实际行为,并明确不支持时是失败还是降级。

删除 ​

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

运行:

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/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 ​

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 实现,常见模式包括 *.txt、iron-*.{txt,md}。目录顺序未指定,要求稳定展示或测试时自行排序。

DirectoryStream.iterator() 只能取得一个迭代器,再次调用会抛 IllegalStateException。该迭代器是线程安全、弱一致的,但不会冻结目录;创建后发生的变化可能被看到,也可能看不到。遍历 I/O 错误包装为 DirectoryIteratorException。

7. 递归遍历:walk、find 与 walkFileTree ​

Files.walk ​

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

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

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

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 对特殊文件未必代表可读字节总数。修改时间精度和可用属性依文件系统而定。

默认 walk 不递归跟随符号链接,但 Files.isRegularFile(path) 默认会跟随链接查询目标;本页过滤器显式传入 NOFOLLOW_LINKS,排除链接本身。启用 FileVisitOption.FOLLOW_LINKS 后,链接可能离开原始树或形成环。Java 会尝试检测循环,并可能抛 FileSystemLoopException。扫描用户可写目录时,先定义是否允许链接;安全敏感操作通常禁用跟随。

POSIX 权限只在支持 PosixFileAttributeView 的 provider 上可用。跨平台程序应查询支持情况,而不是假定 rwx 权限存在。

9. 临时文件后替换 ​

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

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

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

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

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

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

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 转成绝对规范化路径,再使用 Path 组件比较,因此 ../outside.txt 和前缀相似的兄弟目录会被拒绝。它允许解析到 base 自身,也允许 base 内的绝对路径;是否只接受单个文件名需要另一层规则。

下面的独立程序验证正常子路径与两个逃逸输入:

java
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");
    }
}
text
Lexical path checks passed

这只挡住词法 .. 逃逸。攻击者若能在 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:通过已打开目录进行相对操作,以降低部分竞争条件风险。

如果目录中的文件从十个增加到一百万个,排序、扫描和索引各会增加多少时间与内存?第二卷从这个问题开始。你将学习怎样描述输入规模、计算步骤和内存增长,而不是凭一次计时判断算法。

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

Built with VitePress | Software Systems Atlas