Commons IO 与 IO 综合应用 | JavaSE

Commons IO 与 IO 综合应用

一、学习目标

完成本章后,你应该能够:

  • 能够解释 Apache Commons IO 是什么,以及它与 JDK 原生 IO/NIO 的关系。
  • 能够在 Maven 项目中引入 Commons IO。
  • 能够使用 FileUtils 完成文件复制、目录复制、目录删除等常见操作。
  • 能够使用 FileUtils 按指定字符集读取和写入文本。
  • 能够判断什么时候适合直接使用 Commons IO,什么时候应该使用 JDK 原生 IO 流。
  • 能够从需求出发正确选择字节流、字符流、缓冲流、转换流、打印流和数据流。
  • 能够使用 try-with-resources 管理 IO 资源。
  • 能够综合 File、递归、字符集、集合、Stream 与 IO 完成实际文件处理任务。
  • 能够建立完整的 Java File 与 IO 知识体系。

二、核心知识

2.1 为什么还需要 Commons IO

到现在为止,我们已经可以自己完成文件复制。

例如:

try (
        BufferedInputStream input =
                new BufferedInputStream(
                        new FileInputStream("source.jpg")
                );

        BufferedOutputStream output =
                new BufferedOutputStream(
                        new FileOutputStream("target.jpg")
                )
) {

    byte[] buffer = new byte[8192];

    int len;

    while ((len = input.read(buffer)) != -1) {
        output.write(buffer, 0, len);
    }
}

代码没有问题。

但如果实际开发中经常需要:

复制文件
复制整个目录
删除整个目录
读取整个文本文件
写入整个文本文件
获取文件大小
遍历文件

每一次都重新编写:

创建流
↓
循环读取
↓
循环写入
↓
处理目录
↓
递归
↓
资源释放
↓
异常处理

会出现大量重复代码。

于是就产生一个非常自然的工程思想:

把大量重复、通用而且已经成熟的 IO 操作封装成工具 API。

Apache Commons IO 就是用于解决这类问题的成熟 Java IO 工具库。


2.2 什么是 Apache Commons IO

Apache Commons IO 是 Apache 软件基金会旗下的一个 Java IO 工具库。

它并不是:

JDK 自带类库

而属于:

第三方开源库

它在 Java 原生:

java.io
java.nio
java.nio.file

能力之上,封装了大量常用操作。

可以粗略理解:

JDK IO / NIO
    ↓
提供基础能力

Commons IO
    ↓
封装大量常用操作
    ↓
减少模板代码

例如原来复制文件可能需要十几行:

InputStream
+
OutputStream
+
byte[]
+
while
+
try-with-resources

使用 Commons IO:

FileUtils.copyFile(source, target);

即可表达整个业务意图。

这就是工具库的价值:

不是提供计算机本来不能做的事情,而是把重复的底层操作封装起来,提高开发效率和代码可读性。


2.3 Commons IO 不等于 Java IO

这一点必须分清。

Java 原生 IO:

InputStream
OutputStream
Reader
Writer
File
Files
Path

属于:

JDK

Commons IO:

FileUtils
IOUtils
FilenameUtils
FileSystemUtils
...

来自:

Apache Commons IO

因此:

import org.apache.commons.io.FileUtils;

必须先在项目中加入 Commons IO 依赖。

否则 IDE 会出现类似:

Cannot resolve symbol 'FileUtils'

2.4 Commons IO 最重要的 FileUtils

当前阶段最需要掌握的是:

org.apache.commons.io.FileUtils

它是:

面向文件与目录操作的工具类。

原教学体系中重点要求掌握:

| 方法 | 作用 | | ------------------------ | ------------------ | | copyFile(...) | 复制文件 | | copyDirectory(...) | 复制目录 | | deleteDirectory(...) | 删除目录 | | readFileToString(...) | 将文件读取成字符串 | | writeStringToFile(...) | 将字符串写入文件 |

不要试图背完整个 FileUtils

学习工具类真正应该掌握的是:

知道它解决什么问题
+
知道典型 API
+
需要时能够查 API 文档

而不是:

把一个工具类的一百多个方法全部背下来。


三、使用方法

3.1 Maven 引入 Commons IO

如果项目使用 Maven,可以在:

pom.xml

中加入依赖:

<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.22.0</version>
</dependency>

然后:

import org.apache.commons.io.FileUtils;

即可使用。

完整关系:

pom.xml
   ↓
声明 commons-io
   ↓
Maven 下载 jar
   ↓
加入项目 classpath
   ↓
import FileUtils
   ↓
调用 API

3.2 使用 FileUtils.copyFile() 复制文件

原始 IO:

try (
        InputStream input =
                new BufferedInputStream(
                        new FileInputStream("source.jpg")
                );

        OutputStream output =
                new BufferedOutputStream(
                        new FileOutputStream("target.jpg")
                )
) {

    byte[] buffer = new byte[8192];

    int len;

    while ((len = input.read(buffer)) != -1) {
        output.write(buffer, 0, len);
    }
}

Commons IO:

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;

public class CopyFileDemo {

    public static void main(String[] args) throws IOException {

        File source =
                new File("data/source.jpg");

        File target =
                new File("backup/source.jpg");

        FileUtils.copyFile(source, target);
    }
}

核心代码只有:

FileUtils.copyFile(source, target);

语义非常明确:

复制 source
↓
到 target

3.3 copyFile() 仍然是字节复制

虽然我们没有手写:

InputStream
OutputStream
byte[]

但这并不意味着:

文件复制已经不需要字节了。

磁盘中的文件最终依然是:

byte

Commons IO 只是替我们:

封装了读取
+
封装了写入
+
封装了资源管理
+
封装了大量边界处理

所以学习 Commons IO 之前必须先学原生 IO。

否则只会:

FileUtils.copyFile(...)

却不知道:

它解决什么问题
为什么图片不能用字符流复制
为什么 IO 会有异常
为什么需要关闭资源

这就是“会调用 API”和“理解技术”的区别。


3.4 使用 copyDirectory() 复制目录

以前复制目录需要:

遍历目录
↓
判断文件/文件夹
↓
创建目标目录
↓
递归
↓
复制文件

Commons IO:

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;

public class CopyDirectoryDemo {

    public static void main(String[] args) throws IOException {

        File sourceDir =
                new File("data");

        File targetDir =
                new File("backup");

        FileUtils.copyDirectory(
                sourceDir,
                targetDir
        );
    }
}

它会递归处理源目录中的内容。

可以抽象成:

data
├── article.txt
├── image.jpg
└── docs
    ├── java.md
    └── mysql.md

经过复制:

backup
├── article.txt
├── image.jpg
└── docs
    ├── java.md
    └── mysql.md

这正是我们之前:

File
+
listFiles()
+
递归
+
字节流

手写实现过的能力。


3.5 copyDirectory() 和 copyDirectoryToDirectory()

这两个方法容易混淆。

假设:

source
├── a.txt
└── b.txt

目标:

backup

调用:

FileUtils.copyDirectory(
        new File("source"),
        new File("backup")
);

核心语义是:

source 中的内容复制到 backup 中。

结果类似:

backup
├── a.txt
└── b.txt

而:

FileUtils.copyDirectoryToDirectory(
        new File("source"),
        new File("backup")
);

表示:

source 这个目录本身复制到 backup 目录下面。

结果:

backup
└── source
    ├── a.txt
    └── b.txt

因此:

copyDirectory
=
复制目录内容到目标目录

copyDirectoryToDirectory
=
把整个源目录放到目标目录中

实际使用时必须先理解目标目录结构。


3.6 删除整个目录

原生 Java 删除目录时:

file.delete();

如果目录中还有内容:

通常不能直接删除

需要:

递归删除所有子文件
↓
递归删除所有子目录
↓
最后删除当前目录

Commons IO:

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;

public class DeleteDirectoryDemo {

    public static void main(String[] args) throws IOException {

        File directory =
                new File("temp");

        FileUtils.deleteDirectory(directory);
    }
}

一句:

FileUtils.deleteDirectory(directory);

即可递归删除整个目录。


3.7 删除目录是高风险操作

假设:

FileUtils.deleteDirectory(
        new File("D:/")
);

那么操作目标就完全不同了。

因此真实项目中进行删除操作之前至少应该确认:

路径是否正确
↓
路径是否存在
↓
是否真的是目标目录
↓
是否允许删除
↓
是否需要二次确认

例如:

File directory =
        new File("data/temp");

if (directory.exists()
        && directory.isDirectory()) {

    FileUtils.deleteDirectory(directory);
}

但即使这样,也不能代替:

正确设计删除边界。

文件系统操作通常是:

不可逆或高成本操作

所以一定要谨慎。


3.8 将整个文本文件读取成 String

原生写法可能是:

StringBuilder builder = new StringBuilder();

try (
        BufferedReader reader =
                new BufferedReader(
                        new FileReader(
                                "article.txt",
                                StandardCharsets.UTF_8
                        )
                )
) {

    String line;

    while ((line = reader.readLine()) != null) {

        builder.append(line);
        builder.append(System.lineSeparator());
    }
}

Commons IO:

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class ReadTextDemo {

    public static void main(String[] args) throws IOException {

        File file =
                new File("article.txt");

        String content =
                FileUtils.readFileToString(
                        file,
                        StandardCharsets.UTF_8
                );

        System.out.println(content);
    }
}

核心:

String content =
        FileUtils.readFileToString(
                file,
                StandardCharsets.UTF_8
        );

3.9 为什么读取文本必须考虑 Charset

假设:

article.txt

真实字符集是:

UTF-8

那么应该:

FileUtils.readFileToString(
        file,
        StandardCharsets.UTF_8
);

过程仍然是:

文件 byte
↓
UTF-8 解码
↓
Java String

Commons IO 并没有消灭:

编码 / 解码

它只是封装了操作过程。

所以前面的:

字符集
编码
解码
乱码
转换流

知识依然有效。


3.10 String 写入文件

例如:

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

public class WriteTextDemo {

    public static void main(String[] args) throws IOException {

        File file =
                new File("result.txt");

        String content =
                "Java\nMySQL\nSpring Boot";

        FileUtils.writeStringToFile(
                file,
                content,
                StandardCharsets.UTF_8
        );
    }
}

数据过程:

Java String
↓
UTF-8 编码
↓
byte
↓
result.txt

3.11 追加写入

如果希望追加:

FileUtils.writeStringToFile(
        file,
        content,
        StandardCharsets.UTF_8,
        true
);

最后一个:

true

表示:

append
=
追加

如果:

false

则不是追加模式。


3.12 读取多行数据

除了:

readFileToString()

Commons IO 还可以按行读取:

List<String> lines =
        FileUtils.readLines(
                file,
                StandardCharsets.UTF_8
        );

例如:

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class ReadLinesDemo {

    public static void main(String[] args) throws Exception {

        List<String> lines =
                FileUtils.readLines(
                        new File("students.txt"),
                        StandardCharsets.UTF_8
                );

        for (String line : lines) {
            System.out.println(line);
        }
    }
}

现在可以直接得到:

文件
↓
List<String>

然后继续:

集合
+
Lambda
+
Stream

进行数据处理。


四、原理与进阶

4.1 Commons IO 是封装,不是魔法

下面代码:

FileUtils.copyFile(source, target);

看起来像:

一句代码复制文件

但底层仍然必须完成:

打开源文件
↓
读取字节
↓
创建目标文件
↓
写入字节
↓
处理异常
↓
关闭资源

所以应该建立:

高层 API
↓
封装底层通用过程

而不是:

高层 API
↓
绕过 IO 原理

4.2 为什么先学 IO,再学 Commons IO

如果一开始只学习:

FileUtils.copyFile(...)

你可能能复制文件。

但面对:

为什么中文乱码?
为什么图片不能用 Reader?
为什么网络数据也是 InputStream?
为什么需要 try-with-resources?
为什么读取大文件不能直接 readFileToString?
为什么目录复制涉及递归?

就很难解释。

所以合理学习顺序是:

File
↓
递归
↓
字符集
↓
IO 体系
↓
字节流
↓
字符流
↓
缓冲流
↓
转换流
↓
打印流 / 数据流
↓
资源释放
↓
Commons IO

最后才真正获得:

既理解底层,也能够高效开发。


4.3 Commons IO 与 JDK Files

Commons IO 并不是 Java 唯一的便捷文件操作方案。

JDK 自身还有:

java.nio.file.Files

例如:

Files.copy(sourcePath, targetPath);

可以复制文件。

读取字符串:

String content =
        Files.readString(
                path,
                StandardCharsets.UTF_8
        );

写字符串:

Files.writeString(
        path,
        content,
        StandardCharsets.UTF_8
);

所以实际开发中可能同时看到:

java.io
java.nio.file
Apache Commons IO

三种体系。

可以粗略理解:

java.io
→ 经典 IO 流体系
→ 理解 IO 基础的核心

java.nio.file
→ JDK 原生现代文件系统 API

Commons IO
→ 第三方工具库
→ 对大量常见 IO 场景进一步封装

不是:

学了 Commons IO
→ java.io 全部废弃

也不是:

有 Files
→ Commons IO 完全没有价值

正确思想是:

根据实际问题选择抽象层次最合适的 API。


4.4 工具类最重要的是降低重复复杂度

假设业务要求:

复制整个用户上传目录。

如果自己实现,需要考虑:

目录是否存在
目录创建
文件与目录判断
递归
文件复制
异常
资源释放

如果这些全部属于:

通用基础能力

那么在业务项目里反复手写:

没有太大价值

使用成熟工具:

FileUtils.copyDirectory(...)

能够让业务代码更关注:

为什么复制
复制谁
复制到哪里
失败以后怎么办

这体现的其实是一种非常重要的软件工程思想:

复用可靠的基础设施,让代码聚焦业务。


4.5 readFileToString() 为什么不适合任意大文件

代码:

String content =
        FileUtils.readFileToString(
                file,
                StandardCharsets.UTF_8
        );

意味着:

整个文件
↓
一次形成一个 String
↓
进入 JVM 内存

如果文件只有:

5 KB
100 KB
1 MB

通常非常方便。

但是如果文件:

5 GB
20 GB
100 GB

显然不能简单认为:

一把读进 String

是合理方案。

这时应该考虑:

流式读取

例如:

BufferedReader

逐行处理:

读一行
↓
处理一行
↓
丢弃不需要的数据
↓
继续下一行

因此:

API 越方便,不代表任何数据规模下都应该使用。


4.6 工具类无法替你决定业务语义

例如:

FileUtils.copyFile(source, target);

Commons IO 可以解决:

怎么复制

但它不能替你决定:

目标文件已存在怎么办?
应该覆盖吗?
要不要生成新名称?
是否允许复制?
用户有没有权限?
失败是否回滚?
是否记录日志?

这些仍然属于:

业务设计

所以软件开发永远不是:

找到一个 API,问题就全部结束。


五、IO 综合应用

5.1 Java IO 到底应该怎么选

现在已经学习了大量 IO 类。

如果只背类名,很容易混乱。

真正应该从:

我要处理什么数据?

开始。


5.2 第一步:文件还是目录

如果只是表示:

文件路径
目录路径

首先想到:

File

或者现代 JDK:

Path

例如:

判断是否存在
判断文件还是目录
获取文件大小
列出目录内容

这是:

文件系统对象操作

还没有进入数据读写。


5.3 第二步:处理二进制还是文本

如果是:

图片
视频
PDF
压缩包
音频
.class
.exe
任意未知类型文件

优先:

InputStream
OutputStream

也就是:

字节流

典型:

BufferedInputStream
BufferedOutputStream

如果是:

TXT
CSV
Java 源码
Markdown
JSON 文本
XML 文本
日志

并且真正需要:

读取字符内容

优先考虑:

Reader
Writer

也就是:

字符流

典型:

BufferedReader
BufferedWriter

5.4 第三步:是否需要指定字符集

如果:

外部文本编码明确

例如:

UTF-8
GBK

必须关注:

编码 / 解码

可以使用:

InputStreamReader
OutputStreamWriter

完成:

byte ↔ char

或者现代 JDK 的:

FileReader(..., Charset)
FileWriter(..., Charset)

5.5 第四步:是否需要方便打印

如果需求是:

日志
报表
格式化输出
文本结果

可以考虑:

PrintStream
PrintWriter

例如:

writer.printf(
        "%s,%d,%.2f%n",
        name,
        age,
        score
);

5.6 第五步:是否需要保存 Java 基本类型

如果需求是:

int
double
boolean
String

按照确定协议:

以二进制形式保存和恢复

可以:

DataOutputStream
DataInputStream

但是一定遵守:

写入顺序
=
读取顺序

5.7 第六步:是否只是常见文件操作

如果需求只是:

复制文件
复制目录
删除目录
整个文本读取
整个文本写入

可以考虑:

Files

或者:

Commons IO FileUtils

没有必要每一次都重新手写底层循环。


5.8 第七步:资源怎么管理

只要使用:

InputStream
OutputStream
Reader
Writer

等需要关闭的资源,优先:

try-with-resources

例如:

try (
        BufferedReader reader = ...
) {

}

而不是:

创建资源
↓
使用
↓
祈祷中间不出异常
↓
最后手动 close

5.9 Java IO 选型总图

可以把整个 File 与 IO 阶段压缩成下面这张图:

                我要处理文件
                     │
                     ▼
              路径/目录操作?
              │            │
             是            否
              │            │
          File / Path       │
                           ▼
                    要读写数据吗?
                           │
                           ▼
                ┌──────────┴──────────┐
                │                     │
              二进制                 文本
                │                     │
                ▼                     ▼
        InputStream              Reader
        OutputStream             Writer
                │                     │
                ▼                     ▼
       BufferedInputStream      BufferedReader
       BufferedOutputStream     BufferedWriter
                                      │
                                      ▼
                              字符集不一致?
                                │        │
                               是       否
                                │
                                ▼
                         InputStreamReader
                         OutputStreamWriter

其他特殊需求:

格式化文本输出
→ PrintStream / PrintWriter

保存基本数据类型
→ DataOutputStream / DataInputStream

常用文件/目录工具操作
→ Files / Commons IO FileUtils

资源管理
→ try-with-resources

真正掌握这张图,比单纯背二十个 IO 类重要得多。


六、综合案例:文件备份与统计工具

现在将整个 File 与 IO 阶段真正串起来。

6.1 需求分析

假设我们有:

workspace
├── README.md
├── notes.txt
├── image
│   ├── java.png
│   └── mysql.png
└── code
    ├── Main.java
    └── Student.java

开发一个:

文件备份与统计工具

要求:

  1. 判断源目录是否存在。

  2. 将整个目录备份到 backup

  3. 递归统计:

    • 文件总数;
    • 目录总数;
    • 总字节数。
  4. 找出所有:

    • .java
    • .md
    • .txt

    文本文件。

  5. 统计这些文本文件总行数。

  6. 将统计结果写入:

backup-report.txt
  1. 所有文本统一按照:
UTF-8

处理。

这个案例可以把前面的大部分知识串起来。


6.2 技术选型

目录表示:

File

目录备份:

FileUtils.copyDirectory()

递归遍历:

File.listFiles()
+
递归

文本行读取:

BufferedReader

字符集:

StandardCharsets.UTF_8

报告输出:

PrintWriter

资源释放:

try-with-resources

集合处理:

List<File>

这就是:

File
+
递归
+
IO
+
字符集
+
集合
+
Commons IO

的综合应用。


6.3 数据模型

定义统计结果:

public class FileStatistics {

    private int fileCount;

    private int directoryCount;

    private long totalBytes;

    private long textLineCount;

    public int getFileCount() {
        return fileCount;
    }

    public void increaseFileCount() {
        fileCount++;
    }

    public int getDirectoryCount() {
        return directoryCount;
    }

    public void increaseDirectoryCount() {
        directoryCount++;
    }

    public long getTotalBytes() {
        return totalBytes;
    }

    public void addBytes(long bytes) {
        totalBytes += bytes;
    }

    public long getTextLineCount() {
        return textLineCount;
    }

    public void addTextLines(long lines) {
        textLineCount += lines;
    }
}

这里体现:

让对象保存统计状态,而不是把所有变量塞进 main()


6.4 主流程设计

程序流程:

启动程序
   ↓
检查 source
   ↓
不存在?
 ┌─┴─┐
是   否
│     │
结束  ↓
    备份目录
      ↓
    递归统计
      ↓
    文本行统计
      ↓
    生成报告
      ↓
      结束

6.5 判断文本文件

public static boolean isTextFile(File file) {

    String name =
            file.getName().toLowerCase();

    return name.endsWith(".txt")
            || name.endsWith(".md")
            || name.endsWith(".java");
}

6.6 递归统计目录

public static void scan(
        File file,
        FileStatistics statistics
) throws IOException {

    if (file.isFile()) {

        statistics.increaseFileCount();

        statistics.addBytes(
                file.length()
        );

        if (isTextFile(file)) {

            statistics.addTextLines(
                    countLines(file)
            );
        }

        return;
    }

    if (file.isDirectory()) {

        statistics.increaseDirectoryCount();

        File[] children =
                file.listFiles();

        if (children == null) {
            return;
        }

        for (File child : children) {

            scan(
                    child,
                    statistics
            );
        }
    }
}

这里重新应用:

递归出口
+
目录遍历
+
File

6.7 统计文本行数

public static long countLines(
        File file
) throws IOException {

    long count = 0;

    try (
            BufferedReader reader =
                    new BufferedReader(
                            new FileReader(
                                    file,
                                    StandardCharsets.UTF_8
                            )
                    )
    ) {

        while (reader.readLine() != null) {
            count++;
        }
    }

    return count;
}

这里应用:

字符流
+
缓冲流
+
UTF-8
+
try-with-resources

6.8 生成统计报告

public static void writeReport(
        FileStatistics statistics,
        File report
) throws IOException {

    try (
            PrintWriter writer =
                    new PrintWriter(
                            report,
                            StandardCharsets.UTF_8
                    )
    ) {

        writer.println("文件备份统计报告");
        writer.println("====================");

        writer.println(
                "文件数量:" +
                        statistics.getFileCount()
        );

        writer.println(
                "目录数量:" +
                        statistics.getDirectoryCount()
        );

        writer.println(
                "总字节数:" +
                        statistics.getTotalBytes()
        );

        writer.println(
                "文本总行数:" +
                        statistics.getTextLineCount()
        );
    }
}

这里应用:

PrintWriter
+
Charset
+
try-with-resources

6.9 完整主程序

import org.apache.commons.io.FileUtils;

import java.io.*;
import java.nio.charset.StandardCharsets;

public class IoComprehensiveDemo {

    public static void main(String[] args) {

        File source =
                new File("workspace");

        File backup =
                new File("backup");

        File report =
                new File("backup-report.txt");

        if (!source.exists()
                || !source.isDirectory()) {

            System.out.println(
                    "源目录不存在或不是目录"
            );

            return;
        }

        try {

            FileUtils.copyDirectory(
                    source,
                    backup
            );

            FileStatistics statistics =
                    new FileStatistics();

            scan(
                    source,
                    statistics
            );

            writeReport(
                    statistics,
                    report
            );

            System.out.println(
                    "备份与统计完成"
            );

        } catch (IOException e) {

            e.printStackTrace();
        }
    }

    public static void scan(
            File file,
            FileStatistics statistics
    ) throws IOException {

        if (file.isFile()) {

            statistics.increaseFileCount();

            statistics.addBytes(
                    file.length()
            );

            if (isTextFile(file)) {

                statistics.addTextLines(
                        countLines(file)
                );
            }

            return;
        }

        if (file.isDirectory()) {

            statistics.increaseDirectoryCount();

            File[] children =
                    file.listFiles();

            if (children == null) {
                return;
            }

            for (File child : children) {

                scan(
                        child,
                        statistics
                );
            }
        }
    }

    public static boolean isTextFile(
            File file
    ) {

        String name =
                file.getName()
                        .toLowerCase();

        return name.endsWith(".txt")
                || name.endsWith(".md")
                || name.endsWith(".java");
    }

    public static long countLines(
            File file
    ) throws IOException {

        long count = 0;

        try (
                BufferedReader reader =
                        new BufferedReader(
                                new FileReader(
                                        file,
                                        StandardCharsets.UTF_8
                                )
                        )
        ) {

            while (
                    reader.readLine() != null
            ) {

                count++;
            }
        }

        return count;
    }

    public static void writeReport(
            FileStatistics statistics,
            File report
    ) throws IOException {

        try (
                PrintWriter writer =
                        new PrintWriter(
                                report,
                                StandardCharsets.UTF_8
                        )
        ) {

            writer.println(
                    "文件备份统计报告"
            );

            writer.println(
                    "===================="
            );

            writer.println(
                    "文件数量:" +
                            statistics.getFileCount()
            );

            writer.println(
                    "目录数量:" +
                            statistics.getDirectoryCount()
            );

            writer.println(
                    "总字节数:" +
                            statistics.getTotalBytes()
            );

            writer.println(
                    "文本总行数:" +
                            statistics.getTextLineCount()
            );
        }
    }
}

统计类:

public class FileStatistics {

    private int fileCount;

    private int directoryCount;

    private long totalBytes;

    private long textLineCount;

    public int getFileCount() {
        return fileCount;
    }

    public void increaseFileCount() {
        fileCount++;
    }

    public int getDirectoryCount() {
        return directoryCount;
    }

    public void increaseDirectoryCount() {
        directoryCount++;
    }

    public long getTotalBytes() {
        return totalBytes;
    }

    public void addBytes(long bytes) {
        totalBytes += bytes;
    }

    public long getTextLineCount() {
        return textLineCount;
    }

    public void addTextLines(long lines) {
        textLineCount += lines;
    }
}

这个案例最重要的并不是代码本身。

而是你能够看出:

目录备份
→ Commons IO

目录扫描
→ File + 递归

文本判断
→ String API

文本读取
→ BufferedReader

字符集
→ UTF-8

资源管理
→ try-with-resources

状态存储
→ Java 对象

报告输出
→ PrintWriter

这才叫:

综合应用。


六、常见问题

6.1 Commons IO 是 JDK 自带的吗?

不是。

它是:

Apache 第三方开源库

所以必须:

引入依赖

以后才能:

import org.apache.commons.io.FileUtils;

6.2 有 Commons IO 后还需要学习原生 IO 吗?

需要。

而且非常重要。

Commons IO:

解决开发效率

原生 IO:

解决原理理解
+
底层能力
+
复杂场景
+
API 适配

例如网络编程中:

socket.getInputStream()

返回的仍然是:

InputStream

你必须理解:

字节流
缓冲流
转换流
字符集

才能正确处理。


6.3 FileUtils.copyFile() 能复制图片吗?

可以。

它本质完成:

字节复制

因此:

图片
视频
PDF
压缩包
文本

等普通文件都可以进行复制。

复制操作并不需要把文件内容解释成字符。


6.4 readFileToString() 可以读取图片吗?

从 API 参数角度它接收 File

但从业务语义上:

不应该把图片当文本读取。

因为:

readFileToString

意味着:

文件 byte
↓
字符集解码
↓
String

图片本身不是文本数据。

应该使用:

字节处理

而不是:

字符解码

6.5 为什么推荐显式指定 Charset?

不要简单写成:

依赖默认字符集

更清晰的方式:

StandardCharsets.UTF_8

因为这样代码明确表达:

这个文件的编码协议就是 UTF-8

能够显著减少环境差异与乱码问题。


6.6 FileUtils 和 Files 应该选谁?

没有绝对答案。

例如简单复制一个文件:

Files.copy(...)

已经非常方便,就没有必要为了这一行代码强制增加第三方依赖。

但是如果项目本身已经使用 Commons IO,并且需要:

目录递归复制
递归删除
丰富的文件过滤
流工具
文件比较
大量文件工具操作

Commons IO 会非常方便。

原则:

优先选择能够清晰解决问题、依赖合理、团队能够维护的方案。


6.7 Commons IO 会自动处理所有异常吗?

不会。

例如:

FileUtils.copyFile(...)

仍然可能因为:

源文件不存在
权限不足
目标不可写
磁盘异常
路径非法

产生异常。

因此:

FileUtils.copyFile(...)

不是:

永远成功

它只是:

把大量 IO 实现细节封装了。

异常处理仍然是调用者的重要职责。


6.8 为什么工具库版本也要管理?

因为 Commons IO 属于:

外部依赖

随着版本变化可能出现:

新增 API
废弃 API
行为改进
漏洞修复
兼容性变化

所以真实项目应该:

使用 Maven / Gradle 管理依赖
+
明确版本
+
升级时查看官方 Release Notes

而不是随便从网上下载:

一个不知道版本的 jar

丢到项目里。


七、练习与验收

7.1 知识问答

  • [ ] Apache Commons IO 是什么?
  • [ ] Commons IO 是 JDK 自带的吗?
  • [ ] 为什么已经有 Java IO 还需要 Commons IO?
  • [ ] FileUtils 的主要作用是什么?
  • [ ] 如何使用 copyFile() 复制文件?
  • [ ] 如何使用 copyDirectory() 复制整个目录?
  • [ ] copyDirectory()copyDirectoryToDirectory() 有什么区别?
  • [ ] 如何递归删除整个目录?
  • [ ] readFileToString() 适合处理什么数据?
  • [ ] 为什么读取文本仍然必须考虑字符集?
  • [ ] 为什么不能用 readFileToString() 随意处理图片?
  • [ ] writeStringToFile() 如何实现追加写入?
  • [ ] Commons IO 与 JDK Files 是什么关系?
  • [ ] 有 Commons IO 后为什么仍然要学习原生 IO?
  • [ ] 为什么超大文件通常不应该整体读取成一个 String
  • [ ] Commons IO 能否替代异常处理和业务校验?

7.2 代码阅读

阅读:

File source =
        new File("a.jpg");

File target =
        new File("backup/a.jpg");

FileUtils.copyFile(
        source,
        target
);

回答:

  • [ ] 它复制的是字符还是字节?
  • [ ] 图片是否需要指定 UTF-8?
  • [ ] 为什么?

阅读:

String content =
        FileUtils.readFileToString(
                new File("article.txt"),
                StandardCharsets.UTF_8
        );

回答:

  • [ ] 文件中的数据最开始是什么形式?
  • [ ] UTF_8 在哪一步发挥作用?
  • [ ] 最终为什么得到 String
  • [ ] 如果文件真实编码为 GBK,会发生什么?

7.3 手写代码

关闭 AI 自动补全完成:

  • [ ] 引入 Commons IO Maven 依赖。
  • [ ] 使用 FileUtils.copyFile() 复制一个文件。
  • [ ] 使用 FileUtils.copyDirectory() 复制整个文件夹。
  • [ ] 使用 FileUtils.deleteDirectory() 删除测试目录。
  • [ ] 使用 readFileToString() 读取 UTF-8 文本。
  • [ ] 使用 writeStringToFile() 写 UTF-8 文本。
  • [ ] 使用追加模式向日志文件增加数据。
  • [ ] 使用 readLines() 将文本转换成 List<String>
  • [ ] 使用原生字节流重新实现一次 copyFile() 的核心功能。

最后比较:

自己实现
vs
Commons IO

各自的职责和价值。


7.4 Debug

下面程序:

String content =
        FileUtils.readFileToString(
                new File("image.jpg"),
                StandardCharsets.UTF_8
        );

System.out.println(content);

要求:

  • [ ] 代码调用形式是否成立?
  • [ ] 业务逻辑为什么错误?
  • [ ] 图片应该使用什么数据模型处理?
  • [ ] 修正设计。

下面代码:

FileUtils.deleteDirectory(
        new File(userInput)
);

要求:

  • [ ] 找出潜在风险。
  • [ ] 为什么不能直接信任用户输入路径?
  • [ ] 删除前应该验证哪些条件?
  • [ ] 设计一个更安全的删除流程。

7.5 综合训练

完成一个:

Java 文件备份中心

需求:

用户输入源目录
↓
验证目录
↓
扫描文件
↓
显示文件数量与大小
↓
备份到 backup
↓
找出所有 .txt/.md/.java
↓
统计文本总行数
↓
生成 backup-report.txt

要求至少使用:

File
递归
Commons IO
BufferedReader
PrintWriter
Charset
try-with-resources

在编码前必须先完成:

  1. 需求拆分。
  2. 数据流设计。
  3. 方法划分。
  4. 文件结构设计。
  5. 伪代码。

禁止直接把所有代码塞进:

main()

7.6 本章验收

不查看资料,能够先完整画出:

Java File 与 IO
│
├── File / Path
│   └── 文件系统对象
│
├── 字节流
│   ├── InputStream
│   └── OutputStream
│
├── 字符流
│   ├── Reader
│   └── Writer
│
├── 缓冲流
│   ├── BufferedInputStream
│   ├── BufferedOutputStream
│   ├── BufferedReader
│   └── BufferedWriter
│
├── 转换流
│   ├── InputStreamReader
│   └── OutputStreamWriter
│
├── 打印流
│   ├── PrintStream
│   └── PrintWriter
│
├── 数据流
│   ├── DataInputStream
│   └── DataOutputStream
│
├── 资源管理
│   └── try-with-resources
│
└── 工具 API
    ├── Files
    └── Commons IO / FileUtils

然后面对一个需求:

将一个包含图片、Markdown、Java 源码和普通文本的目录完整备份,并统计其中所有文本文件的总行数。

能够主动完成:

分析数据类型
↓
选择 API
↓
设计递归
↓
确定字符集
↓
设计资源释放
↓
编码实现
↓
异常处理

而不是看到文件问题就随机尝试:

FileReader?
FileInputStream?
BufferedReader?
FileUtils?

最后应该能够回答下面这个问题:

面对任何一个基础文件 IO 需求,我为什么选择这个流或这个 API,而不是另外一个?

如果能够回答并独立实现,那么 GROUP 07 · File 与 IO 的核心学习目标就真正完成了。