1. 概述

统计目录及其子目录下的文件数量是编程中的常见任务,无论是构建备份工具、监控磁盘使用情况,还是跨系统同步文件都可能用到。对于Java开发者来说,这个看似简单的问题,正好可以用来探索传统与现代文件处理方案的差异。

本教程将深入两种主要方法:大家熟悉的递归式java.io.File方案,以及Java 7引入的更高效的NIO方案。

2. 准备工作

统计目录及其子目录文件数量需要遍历可能复杂的树形结构,其中每个子目录可能包含更多文件或嵌套子目录。这种递归特性带来了技术挑战:如何确保所有层级的统计准确性?如何避免符号链接导致的无限循环?如何处理大规模数据集的性能问题?

对于Java开发者,解决方案在于选择合适的工具并设计清晰灵活的方法。我们先定义后续实现将使用的方法签名:

public long numberOfFilesIn(String path) {
    // TODO: 实现 
}

该方法接受String path作为起始目录路径,返回long类型的文件数量。使用long类型确保能处理超过32位int限制的计数,这对企业级目录结构至关重要。

3. 使用java.io.File

首先我们使用java.io.File库来实现文件读取和统计。

3.1. Java 8+ 实现

从Java 8开始,我们可以使用函数式风格Streams来编写代码。遍历目录、查找文件并深入嵌套子目录的任务具有重复性,因此递归是天然的选择:

File currentFile = new File(path);
File[] filesOrNull = currentFile.listFiles();
// 当前路径是否直接指向文件?
long currentFileNumber = currentFile.isFile() ? 1 : 0;
if (filesOrNull == null) { // 无子目录
    return currentFileNumber; // 终止条件 #1
}
return currentFileNumber + Arrays.stream(filesOrNull)
  .mapToLong(FindFolder::filesInside) // <-- 递归调用
  .sum();

实现包含三个核心部分:

  1. 检查当前路径是文件还是目录
  2. 如果当前目录无法列出文件,立即返回
  3. 否则递归统计子目录文件并返回总和(含当前文件计数)
private static long filesInside(File it) {
    if (it.isFile()) {
        return 1; // 终止条件 #2
    } else if (it.isDirectory()) {
        return numberOfFilesIn(it.getAbsolutePath()); // <-- 递归调用
    } else {
        return 0; // 终止条件 #3
    }
}

⚠️ 注意:我们设置了三个终止条件来防止递归失控。忽略递归终止条件可能导致内存溢出异常。

3.2. Java 8 之前版本

对于Java 8以下版本,我们可以将上述流式实现重构为传统循环:

for (File file : filesOrNull) {
    if (file.isDirectory()) {
        currentFileNumber += numberOfFilesIn(file.getAbsolutePath());
    } else if (file.isFile()) {
        currentFileNumber += 1; // 累加文件计数
    }
}
return currentFileNumber;

这个版本与之前流式实现的映射函数非常相似,只是直接修改了currentFileNumber变量。尽管存在可变性争议,但该方案以简洁的代码和良好的可读性胜出。

4. 使用NIO

Java 7引入了NIO作为文件系统操作的替代方案。

4.1. Files.find 方案

第三种实现使用Files.find功能:

try (Stream<Path> stream = Files.find(
  Paths.get(path), 
  Integer.MAX_VALUE,
  (__, attr) -> attr.isRegularFile())) {
    return stream.count();
} catch (IOException e) {
    // 或在此处记录日志
    throw new RuntimeException(e);
}

我们使用try-with-resources在初始路径目录内打开Path流。

4.2. 遍历方案

第二种NIO方案是"遍历"文件系统,使用Files.walk实现:

Path dir = Path.of(path);
try (Stream<Path> stream = Files.walk(dir)) {
    return stream.parallel()
      .map(getFileOrEmpty())
      .flatMap(Optional::stream)
      .filter(it -> !it.isDirectory())
      .count();
} catch (IOException e) {
    throw new RuntimeException(e);
}

同样地,我们获取初始路径目录内的Path流。由于目录遍历顺序无关紧要且路径数量可能庞大,我们将其转换为并行流。然后过滤掉无法关联到默认提供程序的路径,并用Java Optional包装。最后返回所有非目录(即文件)元素的计数。

private static Function<Path, Optional<File>> getFileOrEmpty() {
    return it -> {
        try {
            return Optional.of(it.toFile());
        } catch (UnsupportedOperationException e) {
            // 可在此处打印或记录异常
            return Optional.empty();
        }
    };
}

提取的getFileOrEmpty方法返回一个映射函数,用于安全地将有效File包装在Optional中。这样做有两个好处:保持调用方法简洁,并处理流中不应忽略的UnsupportedOperationException

5. 综合考虑

实现文件统计方案时需注意几个关键因素:

性能:递归式java.io.File方法在深度目录结构中可能因栈溢出风险而表现不佳,而NIO的流式方案能更好地处理大规模数据集。对于大型目录结构,可考虑使用Files.walk的并行流,但需谨慎处理并发文件修改问题。

安全:如果路径来自用户输入,必须验证以防止目录遍历攻击。

兼容性:利用NIO的Unicode支持处理国际化文件名,避免非ASCII字符问题。

平衡效率、安全性和健壮性,才能确保这些方法满足实际需求。

6. 验证

现在让我们设计一个测试来验证所有实现。由于需要操作真实目录,先创建测试文件结构:

filesToBeFound
|-- file1.txt
|-- subEmptyFolder
|-- subFolder1
    |-- file2.txt
    |-- file3.txt
|-- subFolder2 
    |-- file4.txt
    |-- subSubFolder
        |-- subSubSubFolder
            |-- file5.txt

测试代码如下:

private final String resourcePath = this.getClass().getResource("/filesToBeFound").getPath();

@Test
void shouldReturnNumberOfAllFilesInsidePath() {
    assertThat(FindFolder.numberOfFilesIn(resourcePath)).isEqualTo(5);
}

所有实现都应在测试目录中找到相同的5个文件。

7. 结论

本教程探讨了Java中统计目录及其子目录文件数量的两种主流方案:

  • java.io.File方法:凭借递归的简洁性,适合小型浅层目录结构,为开发者提供了清晰的切入点。但其对递归的依赖在深度层级中可能失效,存在栈溢出风险。
  • NIO方案(Files.findFiles.walk):通过流机制提供效率和可扩展性,能更好地处理大规模数据集。并行Files.walk选项进一步优化了大型目录的性能,但需要仔细处理异常。

对于大多数现代应用,NIO方案因其健壮性和性能而成为更优选择。简单任务可快速使用java.io.File,但当可扩展性成为关键时,应选择NIO方案。


原始标题:Get Number of Files in a Directory and Its Subdirectories in Java | Baeldung