qinghuan
← Java SE / 全部笔记

单元 57 / Java SE

P1 · 重点提升L2 · 进阶

NIO 文件操作:路径解析、目录遍历与文件替换

分清路径文字和实际文件,再用 Path 与 Files 完成目录遍历、复制和移动,检查覆盖与并发变化。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能限制范围遍历文件,并说明路径规范化与实际文件访问的区别。

学习理由、准备要求与知识点

为什么学它

批量文件处理和产物生成需要明确路径解析、遍历资源与覆盖规则。

先读这些内容

知识点清单

  • Path 与文件区别
  • resolve/normalize
  • 绝对路径和真实路径
  • 工作目录
  • Files.walk 关闭
  • 遍历深度和符号链接
  • copy/move 覆盖选项
  • 原子移动适用条件

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

一个路径写得像真的,并不代表文件存在。Path 只是表示路径:resolve 用来组合路径,normalize 主要整理 . 和 .. 这样的片段,toAbsolutePath 转成绝对写法;toRealPath 才会访问文件系统,解析真实目标及链接等关系。normalize 成功不能证明文件存在,也不能单独证明它一定在可信目录内。

Files 可以读写、复制、移动文件,以及查看属性和遍历目录。操作前要明确是否允许覆盖、是否要求原子移动、源和目标是否跨文件系统,并处理不支持的情况。先 exists 检查再写入并不能消除竞争,因为检查后文件还可能被其他程序改变。最终要根据真正的操作结果判断成功,所需的原子保证也必须由对应操作提供。

目录树可以通过 Files.walk 的流式接口处理,也可以使用 walkFileTree 与 FileVisitor 明确控制进入目录、访问文件、失败和离开目录。walk 返回的流持有需要关闭的遍历资源,应放在 try-with-resources 内;它是惰性遍历,创建流成功不代表后续每个目录都能访问。

文中这几个词,先认一认

词法规范化
例如把 a/./b 整理成 a/b,这类做法只按路径的文字规则整理 .、.. 等片段。它不访问文件系统,不证明文件存在,也不能仅靠这一步确认符号链接最终指到哪里。
真实路径
实际访问文件系统后,按规则解析现有文件及链接得到的路径。
符号链接
文件系统里指向另一个位置的链接,路径文字看起来在本目录,实际目标也可能在别处。
访问器遍历
遍历目录时分别处理进入目录、访问文件、发生错误和离开目录这些事件。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

判断文件是否位于允许目录中,不能只比较路径字符串的开头:data 和 database 虽然前几个字母相同,却不是父子目录。Path.startsWith 会按当前文件系统的路径组件来比较,更适合做这一步。但这仍只是路径写法的检查。符号链接可能指向别处,文件也可能在检查后被替换,所以还要区分三件事:文字上位于哪里、当时解析到哪个对象,以及最终真正打开了哪个对象。

遍历默认是否跟随符号链接、最大深度如何计数以及失败如何传播,都要阅读所选 API 契约。跟随链接可能遇到循环,遍历器会按其能力检测部分循环情形;应用仍需决定跳过、记录还是终止。不能为了处理某个权限错误无差别忽略所有访问失败,否则得到的结果可能是不完整清单却没有提示。

压缩包或上传清单中的路径通常先是一种逻辑格式,未必与当前操作系统原生 Path 一样。下面算法明确只接受斜杠分隔的相对名字,先消除点段并拒绝越过逻辑根;它不创建文件,也不承诺已经处理符号链接、Windows 保留设备名或实际权限。图把逻辑路径校验与文件系统操作分成两阶段。

正在加载流程图…

词法路径检查只是前置层;真实文件身份和操作时竞争仍需目标系统处理。
查看流程图源码(Mermaid)
flowchart TD
 A["外部逻辑路径"] --> B["按约定分隔符拆分"]
 B --> C["消除点段并拒绝越过逻辑根"]
 C --> D["得到规范化相对名称"]
 D --> E["按目标文件系统解析 Path"]
 E --> F["核对真实边界、链接和操作选项"]
 F --> G["执行实际文件操作并处理结果"]

按顺序看,每一步做了什么

  1. 明确逻辑路径格式与目标提供者。
  2. 区分 normalize、absolute 与 realPath。
  3. 用组件规则处理相对路径边界。
  4. 为遍历设置深度与失败策略。
  5. 关闭遍历流并核对操作原子性要求。

03对照实现代码

文件遍历流如何绑定底层迭代资源的关闭 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/nio/file/Files.java · java.nio.file.Files#walk(Path,int,FileVisitOption[])

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.util.Iterator;
import java.util.Spliterators;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
public final class SourceModel {
    public static <T> Stream<T> withOwner(Iterator<T> iterator, AutoCloseable owner) {
        // 模拟惰性迭代包装,不实现真实文件系统遍历。
        return StreamSupport.stream(Spliterators.spliteratorUnknownSize(iterator,0),false)
                .onClose(() -> {
                    try { owner.close(); }
                    catch (Exception e) { throw new IllegalStateException("close failed",e); }
                });
    }
}

真实 Files.walk 创建文件树迭代器,把它包装为流并注册关闭处理,使调用流的 close 能释放遍历资源。模型接受已有迭代器和所有者,只展示惰性流与资源关闭的连接,不访问目录、不模拟文件属性读取,也不等同于完整 FileTreeIterator。

终止操作结束与调用 close 是不同动作。找到第一项后短路可能使迭代器仍持有打开的目录,因此使用 try-with-resources 明确结束作用域很重要。集合来源的 Stream 通常没有同样外部资源,不能用其日常经验推断所有流都无需关闭。

遍历时目录内容可能变化,也可能走到某个子目录才遇到权限或 IO 错误。因此成功创建流,只证明遍历开始了,不证明结果已完整。FileVisitor 可以用失败回调记录哪里没扫到;流式写法也要处理异常,并说明返回的是完整还是部分清单。

模型把关闭异常包装成 IllegalStateException,仅用于展示传播;真实 API 有自己的异常类型和关闭行为。生产代码应依赖公开 Files 文档,而非复制模型当作通用 IO 适配器。源码核验的目标是确认资源绑定位置,文件系统原子性和链接解析仍由其它实现层负责。

代码先调用谁,再调用谁

  1. Files.walk 创建 FileTreeIterator
  2. 把迭代器适配成顺序流
  3. 通过 onClose 绑定迭代器关闭
  4. 终止操作按需消费,作用域结束时关闭流释放资源

04动手写一个例子

规范化受限的相对路径段

输入斜杠分隔的相对逻辑文件名,跳过空段和点段,父段弹出前一段;若越过根、绝对路径、反斜杠、冒号或空字符则拒绝。结果不得为空,不访问真实文件系统。

输入 1
notes/./java/../Main.java
输出 1
notes/Main.java
输入 2
a//b.txt
输出 2
a/b.txt
输入 3
../secret
输出 3
拒绝越根

用双端队列保存已经规范化的路径段。普通段追加,点段与重复分隔产生的空段跳过,父段要求已有前段可弹出。队列为空时遇到父段说明试图越过逻辑根,应立即拒绝,不能把多余父段悄悄保留给后续文件系统解析。

先拒绝绝对与不同格式的输入,防止同一串文本在后续目标系统被解释成盘符或另一种分隔路径。这个受限协议主动不支持反斜杠和冒号,不代表所有操作系统都禁止这些字符。规范化后的名字仍需经过实际目标文件系统的额外约束,尤其是保留名、链接与权限。

结果为空说明输入只表达当前目录或被父段全部抵消,而本题需要具体相对文件名,所以拒绝。算法只处理已按约定解码的文本,不自动进行 URL 解码;如果后续再把百分号序列变成分隔符或父段,先前证明就失效。规范化和后续解释顺序必须一致。

为什么这样写是对的每处理一段后,队列表示此前输入在逻辑根内的规范化路径,且不含空段、点段或未消解父段。

时间 O(L),L 为输入字符长度。额外空间 O(L) 用于分段、队列和结果。

每个字符参与有限次拆分与连接,每个普通段最多入队和出队一次,没有反复扫描已处理前缀。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 38 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.ArrayDeque;
public class Main {
    static String normalize(String input) {
        if (input == null || input.isBlank() || input.startsWith("/")
                || input.indexOf('\\') >= 0 || input.indexOf(':') >= 0 || input.indexOf('\0') >= 0)
            throw new IllegalArgumentException("path format");
        ArrayDeque<String> parts = new ArrayDeque<>();
        for (String part : input.split("/",-1)) {
            if (part.isEmpty() || part.equals(".")) continue;
            if (part.equals("..")) {
                // 父段必须能抵消已有普通段,否则越过逻辑根。
                if (parts.isEmpty()) throw new IllegalArgumentException("outside root");
                parts.removeLast();
            } else parts.addLast(part);
        }
        if (parts.isEmpty()) throw new IllegalArgumentException("no file name");
        return String.join("/",parts); // 输出采用同一种逻辑格式。
    }
    static void check(String wanted, String input) {
        if (!wanted.equals(normalize(input))) throw new AssertionError(input);
    }
    static void reject(String input) {
        try { normalize(input); } catch (IllegalArgumentException e) { return; }
        throw new AssertionError("must reject");
    }
    public static void main(String[] args) {
        // 普通规范化、重复分隔、越根、绝对和不同平台格式。
        check("notes/Main.java","notes/./java/../Main.java");
        check("a/b.txt","a//b.txt");
        check("data.txt","notes/../data.txt");
        reject("../secret");
        reject("/absolute");
        reject("C:/file");
        reject("a\\b");
        reject("a/..");
        System.out.println("PASS 8");
    }
}

这些特殊情况也要试一试

  • 开头绝对分隔符拒绝。
  • 连续父段不能越过逻辑根。
  • 全部抵消后不返回空文件名。
  • 不解码百分号,也不检查真实链接。
进阶选读 · 可选深入:受限目录中的实际创建

场景外部路径最终要写入指定目录,同时存在链接和并发修改。

做法在目标系统上结合组件边界、明确链接策略和能保证相对目录操作的机制,必要时使用 SecureDirectoryStream 等支持;对不存在目标和创建竞争分别设计。

复杂度路径解析与 IO 成本依赖文件系统,不能只用字符串长度表示整个操作耗时。

好处与代价可移植性与强原子边界存在差异,应按实际提供者能力实现并验证,词法检查无法替代这些保证。

05项目里怎么用

批量复制或移动前应明确覆盖与失败恢复策略,先准备可检查的目标清单,再执行受控操作。跨文件系统 move 可能不同于同一文件系统内重命名,ATOMIC_MOVE 也并非所有环境都支持。处理失败时记录哪些步骤已完成,避免把部分操作包装成全部成功。

目录扫描应限制深度、结果数量和错误输出,尤其是用户可控制的树结构。忽略所有异常会得到难以信任的清单,遇错立即终止又可能不满足统计需求;可以返回结果与失败摘要的组合,让调用者知道完整性。遍历顺序通常不应被当作固定展示顺序,需要时显式排序。

路径写得合法,不等于用户有权访问。应用要先规定谁能提供路径、允许哪些目录、是否跟随链接。normalize 只整理部分路径文字,实际打开哪个文件,以及检查后目标是否变化,还要由系统操作和并发情况决定。验证时分开检查路径逻辑与真实文件系统行为。

容易踩的坑

  • 用字符串前缀判断目录包含。
  • 把 normalize 当成访问文件并解析所有符号链接。
  • Files.walk 短路后忘记关闭流。
  • 先校验后再次解码,改变已验证路径的含义。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
Files.walk 流组合过滤和映射方便资源关闭与惰性异常需注意简单树扫描与统计
walkFileTree 访问器进入离开和失败策略明确回调代码较多复杂遍历控制与部分失败处理
逻辑路径预校验可离线测试且格式确定不提供真实文件身份保证上传清单、归档条目和受限路径协议

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01normalize 与 toRealPath 最重要的区别是什么?

展开参考答案与推导

normalize 主要处理路径段的词法关系,通常不访问文件;toRealPath 需要解析实际存在目标并按选项处理链接等规则。前者成功不能证明存在、权限或真实目录边界。

02为什么 Files.walk 需要关闭?

展开参考答案与推导

它的惰性遍历可能持有目录资源,终止或短路消费不等同于显式关闭。try-with-resources 把生命周期与作用域绑定,避免留下未释放遍历资源。

03为什么不能用字符串 startsWith 检查目录?

展开参考答案与推导

文本前缀不理解路径段,data 与 database 可能被误判父子。应按 Path 组件语义判断,同时记住链接和实际打开竞争仍需另行处理。

换个情况,还会处理吗

01纯路径测试都过了,真实写入仍越出目录,可能漏什么?

展开参考答案与推导

可能后续解码改变了路径,或实际目录包含符号链接、提供者语义与逻辑格式不同。追踪每层转换和真实文件身份,不能把词法规范化当作完整文件系统边界。

02扫描只返回部分文件却没报错,先查什么?

展开参考答案与推导

检查是否把访问失败全部吞掉,是否设置深度或过滤条件,以及树是否在扫描中变化。结果应附带完整性或失败摘要,避免用户把部分数据当作完整目录。

03原子移动选项失败后能直接当普通移动吗?

展开参考答案与推导

只有业务明确允许降低原子性保证时才能采用回退,并要让结果与恢复策略体现差异。自动回退会改变故障语义,不能仍宣称操作具有原先要求的原子性。

写代码试一试

01验证多个父段恰好不越根

输入 / 条件
a/b/../../c.txt
输出 / 预期
c.txt

提示 队列逐段弹出。

展开参考答案与推导

加入正常结果断言,再对 a/../../c.txt 断言拒绝。两者只差一个父段,能够检查算法是否精确区分抵消已有路径与越过根。

02允许逻辑目录而非仅文件

输入 / 条件
a/..
输出 / 预期
空相对目录表示

提示 先改变空结果契约。

展开参考答案与推导

若业务确实接受根目录,可单独返回专门目录结果而非混同空文件名。后续操作权限也应区分文件与目录,不能只删除非空校验就扩大所有调用范围。

03给扫描结果排序

输入 / 条件
文件访问顺序任意
输出 / 预期
按明确相对路径字典序

提示 遍历顺序不当作展示协议。

展开参考答案与推导

在资源作用域内收集受限数量的相对路径,关闭遍历后按指定比较规则排序。结果很大时考虑外部排序或分页,不能为了稳定显示无上限保留全部路径。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能限制范围遍历文件,并说明路径规范化与实际文件访问的区别。

本篇重点加练

重点训练真实路径与文件替换合同,防止逻辑路径正确但实际写入目标或结果不同。

  • 绝对路径和真实路径
  • copy/move 覆盖选项

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    对 Path.of("lab/a/../b.txt") 写出逻辑解析步骤,说明需要真实文件系统才能回答哪些问题;再比较绝对化与真实路径解析的前置条件。

    提交内容
    路径变换图和文件系统依赖表。
    怎样算完成
    • 路径对象与文件存在分开
    • 词法操作与真实解析区分

    练到的知识点Path 与文件区别 · resolve/normalize · 绝对路径和真实路径

  2. 作业 02排错实验

    只在本题新建的临时目录生成两层文件,限制遍历深度后扫描,记录工作目录和资源关闭;符号链接实验仅在系统支持且目录受控时执行。

    提交内容
    Java 实验、自写深度/空目录测试与运行证据。
    怎样算完成
    • 扫描范围可确认
    • 遍历资源明确关闭
    • 链接政策公开

    练到的知识点工作目录 · Files.walk 关闭 · 遍历深度和符号链接

综合应用

  1. 作业 03设计评审

    程序要用新生成的 report.tmp 替换 report.txt。规定已有目标、跨文件系统、原子移动不支持和写入失败时的用户可见合同。

    提交内容
    替换状态图、失败政策和验收场景。
    怎样算完成
    • 不静默改变原子性承诺
    • 旧文件与临时文件责任明确

    练到的知识点copy/move 覆盖选项 · 原子移动适用条件

  2. 作业 04编程实践

    实现临时目录内的文件清单工具,输出相对路径和文件大小,结果排序稳定;输入不存在、无权限和空目录时有明确合同,不跟随未授权链接。

    提交内容
    Java 工具、自写边界测试和运行证据。
    怎样算完成
    • 只读取限定目录
    • 错误不会伪装为完整结果
    • 遍历和输出规模有预算

    练到的知识点Path 与文件区别 · resolve/normalize · Files.walk 关闭 · copy/move 覆盖选项

  3. 作业 05面试场景

    上传文件名包含 ../、绝对路径或链接路径,开发者只做字符串前缀判断。评审目录限制需要覆盖的威胁与系统前提,不要求编写攻击脚本。

    提交内容
    路径验收集与安全边界说明。
    怎样算完成
    • 词法与真实目标分别验证
    • 并发文件变更被纳入边界

    练到的知识点resolve/normalize · 绝对路径和真实路径 · 工作目录 · 遍历深度和符号链接

  4. 作业 06编程实践

    在本题临时目录实现备份后替换文本文件:源、目标必须通过你定义的真实目标检查,目标已存在或与源为同一文件时有明确政策。

    提交内容
    Java 工具、自写同目标/已存在/失败测试及运行证据。
    怎样算完成
    • 写入前核验只作用题目范围
    • 失败后文件状态可检查

    练到的知识点绝对路径和真实路径 · copy/move 覆盖选项

  5. 作业 07排错实验

    为同一个临时文件构造含 .、.. 和绝对形式的路径,对比词法结果、真实路径与同文件判断;可选链接实验须限定在临时目录内。

    提交内容
    路径样本、自写身份检查和运行记录。
    怎样算完成
    • 字符不同与目标不同分别验证
    • 平台条件公开

    练到的知识点绝对路径和真实路径

  6. 作业 08设计评审

    复制任务要求不能覆盖已有报告,但另一个任务可能同时创建目标。写出成功、已存在与其他失败的合同,并列出何种证据能确认旧报告未被改变。

    提交内容
    操作状态图和并发验收计划。
    怎样算完成
    • 覆盖政策不是仅靠先检查一次
    • 失败后状态有独立核验

    练到的知识点copy/move 覆盖选项

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成文件权限基础后:另一个进程可能在检查与写入之间替换目录。为高风险上传目录设计权限、隔离和操作接口要求,注明跨平台支持限制。

    提交内容
    威胁时间线、平台前提与部署验收条件。
    怎样算完成
    • 检查与使用间隙得到考虑
    • 不把 normalize 描述为完整沙箱

    练到的知识点绝对路径和真实路径 · 遍历深度和符号链接 · copy/move 覆盖选项 · 原子移动适用条件

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    文件同步从同盘目录改为可移动存储,源和目标可能短时不可用。审查真实路径与移动操作的原假设,比较复制完成和源删除的确认边界。

    提交内容
    故障窗口图与同步合同。
    怎样算完成
    • 同盘前提变化明确
    • 部分完成状态可被恢复识别

    练到的知识点绝对路径和真实路径 · copy/move 覆盖选项

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗