原创

Java Files 文件操作教程:创建目录、复制文件与移动文件

程序要把待处理目录里的 CSV 归档:先留一份备份,再把原文件移到月份目录,最后清掉空目录。这里的“复制”和“移动”差别很具体:复制后原文件还在,移动成功后原路径不应再出现;目标已存在时,又不能悄悄覆盖上一份文件。

下面用 JDK 17 的 Path 与 Files 完成这条流程。程序自行创建一份临时练习目录,写入中文文件名和 UTF-8 内容,逐步核对结果,最后只清理自己创建的目录。不需要 Maven,也不接收待删除路径参数,直接运行不会处理现有项目文件。

先确定每一步留下什么

练习开始时只生成一份 incoming/订单.csv。复制后,backup 中有相同内容;移动后,原文件进入 archive/2026-10/orders-202610.csv,incoming 变为空目录。

另一项对照是目录复制:Files.copy(incoming, copiedDirectory) 只会建立目标目录,不会自动带上里面的 CSV。要复制整棵目录,需要明确遍历每个目录与文件,不能把复制单个路径当成递归备份。本文只归档一份文件,目录复制专门用来对照“目录已建成”与“文件已备份”的区别。

保存为 FilesWorkflowDemo.java:

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;

public class FilesWorkflowDemo {
    @FunctionalInterface interface IoAction { void run() throws IOException; }
    static void equal(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + " actual=" + actual);
        }
    }
    static void fails(Class<? extends IOException> type, IoAction action) throws IOException {
        try { action.run(); }
        catch (IOException error) {
            if (type.isInstance(error)) return;
            throw error;
        }
        throw new AssertionError("expected " + type.getSimpleName());
    }
    static List<String> names(Path directory) throws IOException {
        try (Stream<Path> children = Files.list(directory)) {
            return children.map(path -> path.getFileName().toString()).sorted().toList();
        }
    }
    static void deleteOwnTree(Path root) throws IOException {
        Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
            @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
                    throws IOException {
                Files.delete(file);
                return FileVisitResult.CONTINUE;
            }
            @Override public FileVisitResult postVisitDirectory(Path directory, IOException error)
                    throws IOException {
                if (error != null) throw error;
                Files.delete(directory);
                return FileVisitResult.CONTINUE;
            }
        });
    }
    public static void main(String[] args) throws IOException {
        Path root = Files.createTempDirectory("files-workflow-");
        try {
            Path incoming = root.resolve("incoming");
            Path backup = root.resolve("backup");
            Path archive = root.resolve("archive").resolve("2026-10");
            Files.createDirectories(incoming);
            Files.createDirectories(backup);
            Files.createDirectories(archive);
            Files.createDirectories(archive); // 已存在的目录可以复用。
            Path source = incoming.resolve("订单.csv");
            String content = "sku,quantity\nBOOK-01,2\nPEN-01,3\n";
            Files.writeString(source, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW);

            Path directoryOnly = root.resolve("directory-only");
            Files.copy(incoming, directoryOnly);
            equal(List.of(), names(directoryOnly));
            System.out.println("DIRECTORY_COPY_CHILDREN=" + names(directoryOnly));

            Path saved = backup.resolve(source.getFileName());
            Files.copy(source, saved);
            equal(content, Files.readString(saved, StandardCharsets.UTF_8));
            equal(true, Files.exists(source));
            fails(FileAlreadyExistsException.class, () -> Files.copy(source, saved));
            System.out.println("COPIED_CONTENT_MATCH=true");

            Path archived = archive.resolve("orders-202610.csv");
            Files.move(source, archived);
            equal(false, Files.exists(source));
            equal(content, Files.readString(archived, StandardCharsets.UTF_8));
            equal(List.of("orders-202610.csv"), names(archive));
            fails(FileAlreadyExistsException.class, () -> Files.move(saved, archived));
            equal(true, Files.exists(saved));
            equal(content, Files.readString(archived, StandardCharsets.UTF_8));
            System.out.println("MOVE_SOURCE_EXISTS=" + Files.exists(source));
            System.out.println("ARCHIVE_FILES=" + names(archive));
            System.out.println("OVERWRITE_BLOCKED=true");

            Files.delete(incoming); // 原文件已经移走,此时是空目录。
            fails(DirectoryNotEmptyException.class, () -> Files.delete(backup));
            equal(false, Files.deleteIfExists(root.resolve("missing.txt")));
            equal(backup, root.resolve("archive/../backup").normalize());
            System.out.println("NONEMPTY_DELETE_BLOCKED=true");
            System.out.println("MISSING_DELETE=false");
            System.out.println("NORMALIZED_PATH_MATCH=true");
        } finally {
            deleteOwnTree(root);
        }
        equal(false, Files.exists(root));
        System.out.println("CLEANUP_COMPLETE=true");
    }
}

运行:

javac -encoding UTF-8 FilesWorkflowDemo.java
java FilesWorkflowDemo

在 JDK 17 的默认本地文件系统上,预期输出为:

DIRECTORY_COPY_CHILDREN=[]
COPIED_CONTENT_MATCH=true
MOVE_SOURCE_EXISTS=false
ARCHIVE_FILES=[orders-202610.csv]
OVERWRITE_BLOCKED=true
NONEMPTY_DELETE_BLOCKED=true
MISSING_DELETE=false
NORMALIZED_PATH_MATCH=true
CLEANUP_COMPLETE=true

程序运行完会删除练习目录,因此不会在当前目录留下订单文件。若希望观察中间状态,可以在删除前用调试器暂停,并查看 root 的实际值;不要为观察结果而把清理函数改成指向某个已有业务目录。

Path 负责描述位置,Files 才执行操作

root.resolve("archive").resolve("2026-10") 拼出目标位置,本身没有创建任何目录。只有 createDirectories 才真正落地。它会补齐缺失的父目录,适合这里的多层月份路径;createDirectory 只创建当前一级,父目录不存在时会失败。

路径不必用字符串加斜杠拼接,文件名也通过 getFileName 取得。不过 resolve 不是一个“保证输入留在根目录下”的安全过滤器:参数如果本来就是绝对路径,结果可以变成那条绝对路径。本例所有路径片段由程序固定,未接收用户提供的服务器文件地址。

normalize 只是按路径元素处理 . 与 ..,不负责访问文件或确认两个地址指向同一个实体。示例没有符号链接,所以 archive/../backup 的规范化结果与 backup 路径相同;换成涉及链接的路径,就不能用这一步代替实际文件身份与访问范围检查。

目标存在,先决定是否应该覆盖

copy 与 move 都没有传入 REPLACE_EXISTING,所以本例遇到同名目标时拒绝操作。失败之后还检查源文件存在、原归档内容不变,而不是只看抛了某个异常就继续打印成功。

如果业务确实要求覆盖,应显式选择 REPLACE_EXISTING,并确定旧文件是否仍有保留价值。先判断 Files.exists(target)、再复制,也不能代替“不覆盖”的操作选项:判断结束到复制开始之间,另一个操作仍可能创建目标。这份练习虽然是单线程,也没有把两次调用包装成虚假的原子步骤。

移动还涉及文件系统能力。这里源与目标都在同一临时目录树内,没有要求 ATOMIC_MOVE,也没有承诺断电后不会丢失数据;跨文件系统移动、权限、目标正被使用等情况仍可能产生 IOException。指定原子移动而实现不支持时,应处理相应失败,不能悄悄把普通移动描述成同等保证。

文本内容只是小型 CSV,明确以 UTF-8 写入和读回;这不是大文件读取方案。复制本身搬运字节,不需要把文件先读成 String 再写出去。程序读回文本是为了核对练习结果,不是实现复制的必要中转步骤。

列目录与删目录,都有自己的生命周期

Files.list 返回的流持有目录资源,names 用 try-with-resources 关闭它。排序发生在流内,是为了让输出稳定;目录实际列举顺序并没有因为文件系统这次先返回某个名字就得到承诺。它只列当前目录这一层,不会自动递归查所有子目录。

delete 不负责递归删除。incoming 在文件移走后为空,所以可以删除;backup 仍含备份,默认本地实现会抛 DirectoryNotEmptyException。deleteIfExists 对不存在的目标返回 false,只免掉“不存在”这一种情况,权限错误或非空目录不会因此自动忽略。

程序最后的 deleteOwnTree 才是递归清理:先处理文件,再在 postVisitDirectory 里删除已清空的目录。它只接收刚由 createTempDirectory 返回的那棵树,没有启用跟随符号链接。这个辅助方法服务于练习资源的完整收尾,不能用“目录删不掉就递归删”代替对删除范围的确认。

正文到此结束
评论插件初始化中...
Loading...