Java 编程问题:六、Java I/O 路径、文件、缓冲区、扫描和格式化

182 阅读56分钟

原文:Java Coding Problems

协议:CC BY-NC-SA 4.0

贡献者:飞龙

本文来自【ApacheCN Java 译文集】,自豪地采用谷歌翻译

本章包括 20 个涉及文件 Java I/O 的问题。从操作、行走和观察流文件的路径,以及读/写文本和二进制文件的有效方法,我们将介绍 Java 开发人员可能面临的日常问题。

通过本章所学到的技能,您将能够解决大多数涉及 Java I/O 文件的常见问题。本章中的广泛主题将提供大量有关 Java 如何处理 I/O 任务的信息

问题

为了测试您的 Java I/O 编程能力,请看下面的问题。我强烈建议您在使用解决方案和下载示例程序之前,先尝试一下每个问题:

  1. 创建文件路径:写几个创建几种文件路径的例子(如绝对路径、相对路径等)。
  2. 转换文件路径:写几个转换文件路径的例子(例如,将文件路径转换成字符串、URI、文件等)。
  3. 连接文件路径:写几个连接(组合)文件路径的例子。定义一个固定路径并向其附加其他不同的路径(或用其他路径替换其中的一部分)。
  4. 在两个位置之间构造路径:写出几个例子,在两个给定路径之间(从一条路径到另一条路径)之间构造相对路径。
  5. 比较文件路径:写几个比较给定文件路径的例子。
  6. 遍历路径:编写一个程序,访问一个目录下的所有文件,包括子目录。此外,编写一个程序,按名称搜索文件、删除目录、移动目录和复制目录。
  7. 监视路径:编写多个程序,监视某条路径上发生的变化(如创建、删除、修改)。
  8. “流式传输文件内容”:编写一个流式传输给定文件内容的程序。
  9. 在文件树中搜索文件/文件夹:编写一个程序,在给定的文件树中搜索给定的文件/文件夹。
  10. “高效读写文本文件”:编写几个程序,举例说明高效读写文本文件的不同方法。
  11. 高效读写二进制文件:编写几个程序,举例说明高效读写二进制文件的不同方法。
  12. 在大文件中搜索:编写一个程序,在大文件中高效地搜索给定的字符串。
  13. 将 JSON/CSV 文件作为对象读取:编写一个程序,将给定的 JSON/CSV 文件作为对象读取(POJO)。
  14. 使用临时文件/文件夹:编写几个使用临时文件/文件夹的程序。
  15. 过滤文件:为文件编写多个自定义过滤器。
  16. 发现两个文件之间的不匹配:编写一个程序,在字节级发现两个文件之间的不匹配。
  17. 循环字节缓冲区:编写一个表示循环字节缓冲区实现的程序。
  18. 分词文件:写几个代码片段来举例说明分词文件内容的不同技术。
  19. 将格式化输出直接写入文件:编写一个程序,将给定的数字(整数和双精度)格式化并输出到文件中。
  20. 使用Scanner:写几个代码片段来展示Scanner的功能。

解决方案

以下各节介绍上述问题的解决方案。记住,通常没有一个正确的方法来解决一个特定的问题。另外,请记住,这里显示的解释只包括解决问题所需的最有趣和最重要的细节。您可以从这个页面下载示例解决方案以查看更多详细信息并尝试程序。

129 创建文件路径

从 JDK7 开始,我们可以通过 NIO.2API 创建一个文件路径。更准确地说,可以通过PathPathsAPI 轻松定义文件路径。

Path类是文件系统中路径的编程表示。路径字符串包含以下信息:

  • 文件名
  • 目录列表
  • 依赖于操作系统的文件分隔符(例如,在 Solaris 和 Linux 上为正斜杠/,在 Microsoft Windows 上为反斜杠\
  • 其他允许的字符,例如,.(当前目录)和..(父目录)符号

Path类处理不同文件系统(FileSystem)中的文件,这些文件系统可以使用不同的存储位置(FileStore是底层存储)。

定义Path的一个常见解决方案是调用Paths辅助类的get()方法之一。另一种解决方案依赖于FileSystems.getDefault().getPath()方法。

Path驻留在文件系统中—文件系统存储和组织文件或某种形式的媒体,通常在一个或多个硬盘驱动器上,以便于检索。文件系统可以通过java.nio.file.FileSystemsfinal类获取,用于获取java.nio.file.FileSystem的实例。JVM 的默认FileSystem(俗称操作系统的默认文件系统)可以通过FileSystems().getDefault()方法获得。一旦我们知道文件系统和文件(或目录/文件夹)的位置,我们就可以为它创建一个Path对象。

另一种方法包括从统一资源标识符URI)创建Path。Java 通过URI类包装一个URI,然后我们可以通过URI.create(String uri)方法从一个String获得一个URI。此外,Paths类提供了一个get()方法,该方法将URI对象作为参数并返回相应的Path

从 JDK11 开始,我们可以通过两个方法创建一个Path。其中一个将URI转换为Path,而另一个将路径字符串或字符串序列转换为路径字符串。

在接下来的部分中,我们将了解创建路径的各种方法。

创建相对于文件存储根目录的路径

相对于当前文件存储根目录的路径(例如,C:/)必须以文件分隔符开头。在下面的例子中,如果当前文件存储根为C,则绝对路径为C:\learning\packt\JavaModernChallenge.pdf

Path path = Paths.get("/learning/packt/JavaModernChallenge.pdf");
Path path = Paths.get("/learning", "packt/JavaModernChallenge.pdf");

Path path = Path.of("/learning/packt/JavaModernChallenge.pdf");
Path path = Path.of("/learning", "packt/JavaModernChallenge.pdf");

Path path = FileSystems.getDefault()
  .getPath("/learning/packt", "JavaModernChallenge.pdf");
Path path = FileSystems.getDefault()
  .getPath("/learning/packt/JavaModernChallenge.pdf");

Path path = Paths.get(
  URI.create("file:///learning/packt/JavaModernChallenge.pdf"));
Path path = Path.of(
  URI.create("file:///learning/packt/JavaModernChallenge.pdf"));

创建相对于当前文件夹的路径

当我们创建一个相对于当前工作文件夹的路径时,路径应该以文件分隔符开头。如果当前文件夹名为books并且在C根目录下,那么下面代码段返回的绝对路径将是C:\books\learning\packt\JavaModernChallenge.pdf

Path path = Paths.get("learning/packt/JavaModernChallenge.pdf");
Path path = Paths.get("learning", "packt/JavaModernChallenge.pdf");

Path path = Path.of("learning/packt/JavaModernChallenge.pdf");
Path path = Path.of("learning", "packt/JavaModernChallenge.pdf");

Path path = FileSystems.getDefault()
  .getPath("learning/packt", "JavaModernChallenge.pdf");
Path path = FileSystems.getDefault()
  .getPath("learning/packt/JavaModernChallenge.pdf");

创建绝对路径

创建绝对路径可以通过显式指定根目录和包含文件或文件夹的所有其他子目录来完成,如以下示例(C:\learning\packt\JavaModernChallenge.pdf)所示:

Path path = Paths.get("C:/learning/packt", "JavaModernChallenge.pdf");
Path path = Paths.get(
  "C:", "learning/packt", "JavaModernChallenge.pdf");
Path path = Paths.get(
  "C:", "learning", "packt", "JavaModernChallenge.pdf");
Path path = Paths.get("C:/learning/packt/JavaModernChallenge.pdf");
Path path = Paths.get(
  System.getProperty("user.home"), "downloads", "chess.exe");

Path path = Path.of(
  "C:", "learning/packt", "JavaModernChallenge.pdf");
Path path = Path.of(
  System.getProperty("user.home"), "downloads", "chess.exe");

Path path = Paths.get(URI.create(
  "file:///C:/learning/packt/JavaModernChallenge.pdf"));
Path path = Path.of(URI.create(
  "file:///C:/learning/packt/JavaModernChallenge.pdf"));

使用快捷方式创建路径

我们理解快捷方式是.(当前目录)和..(父目录)符号。这种路径可以通过normalize()方法进行归一化。此方法消除了冗余,例如.directory/..

Path path = Paths.get(
  "C:/learning/packt/chapters/../JavaModernChallenge.pdf")
    .normalize();
Path path = Paths.get(
  "C:/learning/./packt/chapters/../JavaModernChallenge.pdf")
    .normalize();

Path path = FileSystems.getDefault()
  .getPath("/learning/./packt", "JavaModernChallenge.pdf")
    .normalize();

Path path = Path.of(
  "C:/learning/packt/chapters/../JavaModernChallenge.pdf")
    .normalize();
Path path = Path.of(
  "C:/learning/./packt/chapters/../JavaModernChallenge.pdf")
    .normalize();

如果不规范化,路径的冗余部分将不会被删除。

为了创建与当前操作系统 100% 兼容的路径,我们可以依赖于FileSystems.getDefault().getPath(),或者是File.separator(依赖于系统的默认名称分隔符)和File.listRoots()(可用的文件系统根)的组合。对于相对路径,我们可以依赖以下示例:

private static final String FILE_SEPARATOR = File.separator;

或者,我们可以依赖getSeparator()


private static final String FILE_SEPARATOR
  = FileSystems.getDefault().getSeparator();

// relative to current working folder
Path path = Paths.get("learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Path.of("learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Paths.get(String.join(FILE_SEPARATOR, "learning",
  "packt", "JavaModernChallenge.pdf"));
Path path = Path.of(String.join(FILE_SEPARATOR, "learning",
  "packt", "JavaModernChallenge.pdf"));

// relative to the file store root
Path path = Paths.get(FILE_SEPARATOR + "learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Path.of(FILE_SEPARATOR + "learning",
  "packt", "JavaModernChallenge.pdf");

我们也可以对绝对路径执行相同的操作:

Path path = Paths.get(File.listRoots()[0] + "learning",
  "packt", "JavaModernChallenge.pdf");
Path path = Path.of(File.listRoots()[0] + "learning",
  "packt", "JavaModernChallenge.pdf");

根目录列表也可以通过FileSystems获得:

FileSystems.getDefault().getRootDirectories()

130 转换文件路径

将文件路径转换为StringURIFile等是一项常见任务,可以在广泛的应用中发生。让我们考虑以下文件路径:

Path path = Paths.get("/learning/packt", "JavaModernChallenge.pdf");

现在,基于 JDK7 和 NIO.2 API,让我们看看如何将一个Path转换成一个String、一个URI、一个绝对路径、一个路径和一个文件:

  • Path转换为String非常简单,只需(显式地或自动地)调用Path.toString()方法。注意,如果路径是通过FileSystem.getPath()方法获得的,那么toString()返回的路径字符串可能与用于创建路径的初始String不同:
// \learning\packt\JavaModernChallenge.pdf
String pathToString = path.toString();
  • 可以通过Path.toURI()方法将Path转换为URI(浏览器格式)。返回的URI包装了一个可在 Web 浏览器地址栏中使用的路径字符串:
// file:///D:/learning/packt/JavaModernChallenge.pdf
URI pathToURI = path.toUri();

假设我们想要将URI/URL中的文件名提取为Path(这是常见的场景)。在这种情况下,我们可以依赖以下代码片段:

// JavaModernChallenge.pdf
URI uri = URI.create(
  "https://www.learning.com/packt/JavaModernChallenge.pdf");
Path URIToPath = Paths.get(uri.getPath()).getFileName();

// JavaModernChallenge.pdf
URL url = new URL(
  "https://www.learning.com/packt/JavaModernChallenge.pdf");
Path URLToPath = Paths.get(url.getPath()).getFileName();

路径转换可按以下方式进行:

  • 可以通过Path.toAbsolutePath()方法将相对Path转换为绝对Path。如果Path已经是绝对值,则返回相同的结果:
// D:\learning\packt\JavaModernChallenge.pdf
Path pathToAbsolutePath = path.toAbsolutePath();
  • 通过Path.toRealPath()方法可以将Path转换为实际Path,其结果取决于实现。如果所指向的文件不存在,则此方法将抛出一个IOException。但是,根据经验,调用此方法的结果是没有冗余元素的绝对路径(标准化)。此方法获取一个参数,该参数指示应如何处理符号链接。默认情况下,如果文件系统支持符号链接,则此方法将尝试解析它们。如果您想忽略符号链接,只需将LinkOption.NOFOLLOW_LINKS常量传递给方法即可。此外,路径名元素将表示目录和文件的实际名称。

例如,让我们考虑下面的Path和调用此方法的结果(注意,我们故意添加了几个冗余元素并将PACKT文件夹大写):

Path path = Paths.get(
  "/learning/books/../PACKT/./", "JavaModernChallenge.pdf");

// D:\learning\packt\JavaModernChallenge.pdf
Path realPath = path.toRealPath(LinkOption.NOFOLLOW_LINKS);
  • 可通过Path.toFile()方法将Path转换为文件。将一个文件转换成一个Path,我们可以依赖File.toPath()方法:
File pathToFile = path.toFile();
Path fileToPath = pathToFile.toPath();

131 连接文件路径

连接(或组合)文件路径意味着定义一个固定的根路径,并附加一个部分路径或替换其中的一部分(例如,一个文件名需要替换为另一个文件名)。基本上,当我们想要创建共享公共固定部分的新路径时,这是一种方便的技术。

这可以通过 NIO.2 和Path.resolve()Path.resolveSibling()方法来实现。

让我们考虑以下固定根路径:

Path base = Paths.get("D:/learning/packt");

我们还假设我们想要得到两本不同书籍的Path

// D:\learning\packt\JBossTools3.pdf
Path path = base.resolve("JBossTools3.pdf");

// D:\learning\packt\MasteringJSF22.pdf
Path path = base.resolve("MasteringJSF22.pdf");

我们可以使用此函数循环一组文件;例如,让我们循环一String[]本书:

Path basePath = Paths.get("D:/learning/packt");
String[] books = {
  "Book1.pdf", "Book2.pdf", "Book3.pdf"
};

for (String book: books) {
  Path nextBook = basePath.resolve(book);
  System.out.println(nextBook);
}

有时,固定根路径也包含文件名:

Path base = Paths.get("D:/learning/packt/JavaModernChallenge.pdf");

这一次,我们可以通过resolveSibling()方法将文件名(JavaModernChallenge.pdf替换为另一个名称。此方法根据此路径的父路径解析给定路径,如下例所示:

// D:\learning\packt\MasteringJSF22.pdf
Path path = base.resolveSibling("MasteringJSF22.pdf");

如果我们将Path.getParent()方法引入讨论,并将resolve()resolveSibling()方法链接起来,那么我们可以创建更复杂的路径,如下例所示:

// D:\learning\publisher\MyBook.pdf
Path path = base.getParent().resolveSibling("publisher")
  .resolve("MyBook.pdf");

resolve()/resolveSibling()方法分为两种,分别是resolve​(String other)/resolveSibling​(String other)resolve​(Path other)/resolveSibling​(Path other)

132 在两个位置之间构建路径

在两个位置之间构建相对路径是Path.relativize()方法的工作。

基本上,得到的相对路径(由Path.relativize()返回)从一条路径开始,在另一条路径上结束。这是一个强大的功能,它允许我们使用相对路径在不同的位置之间导航,相对路径是根据前面的路径解析的。

让我们考虑以下两种途径:

Path path1 = Paths.get("JBossTools3.pdf");
Path path2 = Paths.get("JavaModernChallenge.pdf");

注意,JBossTools3.pdfJavaModernChallenge.pdf是兄弟姐妹。这意味着我们可以通过上一级然后下一级从一个导航到另一个。以下示例也揭示了这种导航情况:

// ..\JavaModernChallenge.pdf
Path path1ToPath2 = path1.relativize(path2);

// ..\JBossTools3.pdf
Path path2ToPath1 = path2.relativize(path1);

另一种常见情况涉及公共根元素:

Path path3 = Paths.get("/learning/packt/2003/JBossTools3.pdf");
Path path4 = Paths.get("/learning/packt/2019");

所以,path3path4共享相同的根元素/learning。从path3path4需要上两层下一层。另外,从path4path3的航行,需要上一级,下两级。查看以下代码:

// ..\..\2019
Path path3ToPath4 = path3.relativize(path4);

// ..\2003\JBossTools3.pdf
Path path4ToPath3 = path4.relativize(path3);

两个路径都必须包含根元素。完成这个需求并不能保证成功,因为相对路径的构建依赖于实现。

133 比较文件路径

根据我们如何看待两个文件路径之间的相等性,有几种解决方案。主要来说,平等性可以通过不同的方式为不同的目标进行验证。

假设我们有以下三种路径(考虑在您的计算机上复制C:\learning\packt\JavaModernChallenge.pdf

Path path1 = Paths.get("/learning/packt/JavaModernChallenge.pdf");
Path path2 = Paths.get("/LEARNING/PACKT/JavaModernChallenge.pdf");
Path path3 = Paths.get("D:/learning/packt/JavaModernChallenge.pdf");

在下面的部分中,我们将研究用于比较文件路径的不同方法。

Path.equals()

path1等于path2吗?或者,path2等于path3吗?好吧,如果我们通过Path.equals()进行这些测试,那么可能的结果将显示path1等于path2,但path2不等于path3

boolean path1EqualsPath2 = path1.equals(path2); // true
boolean path2EqualsPath3 = path2.equals(path3); // false

Path.equals()方法遵循Object.equals()规范。虽然此方法不访问文件系统,但相等性取决于文件系统实现。例如,一些文件系统实现可能会以区分大小写的方式比较路径,而其他文件系统实现可能会忽略大小写。

表示相同文件/文件夹的路径

然而,这可能不是我们想要的那种比较。如果两条路径是相同的文件或文件夹,那么说它们相等就更有意义了。这可以通过Files.isSameFile()方法来实现。此方法分为两个步骤:

  1. 首先,它调用Path.equals(),如果此方法返回true,则路径相等,无需进一步操作。

  2. 其次,如果Path.equals()返回false,则检查两个路径是否代表同一个文件/文件夹(根据实现,此操作可能需要打开/访问两个文件,因此文件必须存在,以避免出现IOException)。

//true
boolean path1IsSameFilePath2 = Files.isSameFile(path1, path2);
//true
boolean path1IsSameFilePath3 = Files.isSameFile(path1, path3);
//true
boolean path2IsSameFilePath3 = Files.isSameFile(path2, path3);

词典比较

如果我们只需要对路径进行词典比较,那么我们可以依赖于Path.compareTo()方法(这对于排序很有用)。

此方法返回以下信息:

  • 如果路径相等,则为 0
  • 如果第一条路径在词典上小于参数路径,则该值小于零
  • 如果第一条路径在词典中大于参数路径,则该值大于零:
int path1compareToPath2 = path1.compareTo(path2); // 0
int path1compareToPath3 = path1.compareTo(path3); // 24
int path2compareToPath3 = path2.compareTo(path3); // 24

请注意,您可能会获得与上一示例不同的值。此外,在您的业务逻辑中,重要的是依赖于它们的含义,而不是依赖于它们的值(例如,说if(path1compareToPath3 > 0) { ... },避免使用if(path1compareToPath3 == 24) { ... })。

部分比较

部分比较可通过Path.startsWith()Path.endsWith()方法实现。使用这些方法,我们可以测试当前路径是否以给定路径开始/结束:

boolean sw = path1.startsWith("/learning/packt");       // true
boolean ew = path1.endsWith("JavaModernChallenge.pdf"); // true

134 遍历

对于遍历(或访问)路径有不同的解决方案,其中一种由 NIO.2API 通过FileVisitor接口提供。

此接口公开了一组方法,这些方法表示访问给定路径的递归过程中的检查点。通过覆盖这些检查点,我们可以干预这个过程。我们可以处理当前访问的文件/文件夹,并通过FileVisitResult枚举决定应该进一步执行的操作,该枚举包含以下常量:

  • CONTINUE:遍历过程应该继续(访问下一个文件、文件夹、跳过失败等)
  • SKIP_SIBLINGS:遍历过程应继续,而不访问当前文件/文件夹的同级
  • SKIP_SUBTREE:遍历过程应继续,而不访问当前文件夹中的条目
  • TERMINATE:遍历应该残酷地终止

FileVisitor公开的方法如下:

  • FileVisitResult visitFile​(T file, BasicFileAttributes attrs) throws IOException:对每个访问的文件/文件夹自动调用
  • FileVisitResult preVisitDirectory​(T dir, BasicFileAttributes attrs) throws IOException:在访问文件夹内容前自动调用文件夹
  • FileVisitResult postVisitDirectory​(T dir, IOException exc) throws IOException:在目录(包括子目录)中的内容被访问后,或在文件夹的迭代过程中,发生 I/O 错误或访问被编程中止后自动调用
  • FileVisitResult visitFileFailed​(T file, IOException exc) throws IOException:由于不同原因(如文件属性无法读取或文件夹无法打开)无法访问(访问)文件时自动调用

好的,到目前为止,很好!让我们继续几个实际的例子。

琐碎的文件夹遍历

实现FileVisitor接口需要覆盖它的四个方法。然而,NIO.2 附带了这个接口的一个内置的简单实现,称为SimpleFileVisitor。对于简单的情况,扩展这个类比实现FileVisitor更方便,因为它只允许我们覆盖必要的方法。

例如,假设我们将电子课程存储在D:/learning文件夹的子文件夹中,我们希望通过FileVisitorAPI 访问每个子文件夹。如果在子文件夹的迭代过程中出现问题,我们只会抛出报告的异常。

为了塑造这种行为,我们需要覆盖postVisitDirectory()方法,如下所示:

class PathVisitor extends SimpleFileVisitor<Path> {

  @Override
  public FileVisitResult postVisitDirectory(
      Path dir, IOException ioe) throws IOException {

    if (ioe != null) {
      throw ioe;
    }

    System.out.println("Visited directory: " + dir);

    return FileVisitResult.CONTINUE;
  }
}

为了使用PathVisitor类,我们只需要设置路径并调用其中一个Files.walkFileTree()方法,如下所示(这里使用的walkFileTree()的风格得到起始文件/文件夹和相应的FileVisitor

Path path = Paths.get("D:/learning");
PathVisitor visitor = new PathVisitor();

Files.walkFileTree(path, visitor);

通过使用前面的代码,我们将收到以下输出:

Visited directory: D:\learning\books\ajax
Visited directory: D:\learning\books\angular
...

按名称搜索文件

在计算机上搜索某个文件是一项常见的任务。通常,我们依赖于操作系统提供的工具或其他工具,但如果我们想通过编程实现这一点(例如,我们可能想编写一个具有特殊功能的文件搜索工具),那么FileVisitor可以帮助我们以非常简单的方式实现这一点。本申请存根如下:

public class SearchFileVisitor implements FileVisitor {

  private final Path fileNameToSearch;
  private boolean fileFound;
  ...

  private boolean search(Path file) throws IOException {

    Path fileName = file.getFileName();

    if (fileNameToSearch.equals(fileName)) {
      System.out.println("Searched file was found: " +
        fileNameToSearch + " in " + file.toRealPath().toString());

      return true;
    }

    return false;
  }
}

让我们看看主要检查点和按名称搜索文件的实现:

  • visitFile()是我们的主要检查站。一旦有了控制权,就可以查询当前访问的文件的名称、扩展名、属性等。需要此信息才能与搜索文件上的相同信息进行比较。例如,我们比较名字,在第一次匹配时,我们TERMINATE搜索。但是如果我们搜索更多这样的文件(如果我们知道不止一个),那么我们可以返回CONTINUE
@Override
public FileVisitResult visitFile(
  Object file, BasicFileAttributes attrs) throws IOException {

  fileFound = search((Path) file);

  if (!fileFound) {
    return FileVisitResult.CONTINUE;
  } else {
    return FileVisitResult.TERMINATE;
  }
}

visitFile()方法不能用于查找文件夹。改用preVisitDirectory()postVisitDirectory()方法。

  • visitFileFailed()是第二个重要关卡。调用此方法时,我们知道在访问当前文件时出现了问题。我们宁愿忽略任何这样的问题和搜索。停止搜索过程毫无意义:
@Override
public FileVisitResult visitFileFailed(
  Object file, IOException ioe) throws IOException {
  return FileVisitResult.CONTINUE;
}

preVisitDirectory()postVisitDirectory()方法没有任何重要的任务,因此为了简洁起见,我们可以跳过它们。

为了开始搜索,我们依赖另一种风格的Files.walkFileTree()方法。这一次,我们指定搜索的起点(例如,所有根)、搜索期间使用的选项(例如,跟随符号链接)、要访问的最大目录级别数(例如,Integer.MAX_VALUE)和FileVisitor(例如,SearchFileVisitor):

Path searchFile = Paths.get("JavaModernChallenge.pdf");

SearchFileVisitor searchFileVisitor 
  = new SearchFileVisitor(searchFile);

EnumSet opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS);
Iterable<Path> roots = FileSystems.getDefault().getRootDirectories();

for (Path root: roots) {
  if (!searchFileVisitor.isFileFound()) {
    Files.walkFileTree(root, opts,
      Integer.MAX_VALUE, searchFileVisitor);
  }
}

如果您查看本书附带的代码,前面的搜索将以递归方式遍历计算机的所有根(目录)。前面的例子可以很容易地通过扩展名、模式进行搜索,或者从一些文本中查看文件内部。

删除文件夹

在试图删除文件夹之前,我们必须删除其中的所有文件。这个语句非常重要,因为它不允许我们对包含文件的文件夹简单地调用delete()/deleteIfExists()方法。此问题的优雅解决方案依赖于从以下存根开始的FileVisitor实现:

public class DeleteFileVisitor implements FileVisitor {
  ...
  private static boolean delete(Path file) throws IOException {

    return Files.deleteIfExists(file);
  }
}

让我们看看主要检查点和删除文件夹的实现:

  • visitFile()是从给定文件夹或子文件夹删除每个文件的理想位置(如果文件不能删除,则我们只需将其传递到下一个文件,但可以随意调整代码以满足您的需要):
@Override
public FileVisitResult visitFile(
  Object file, BasicFileAttributes attrs) throws IOException {

  delete((Path) file);

  return FileVisitResult.CONTINUE;
}
  • 只有当文件夹为空时,才可以删除它,因此postVisitDirectory()是执行此操作的最佳位置(我们忽略任何潜在的IOException,但可以随意调整代码以满足您的需要(例如,记录无法删除的文件夹的名称或引发异常以停止进程)):
@Override
public FileVisitResult postVisitDirectory(
    Object dir, IOException ioe) throws IOException {

  delete((Path) dir);

  return FileVisitResult.CONTINUE;
}

visitFileFailed()preVisitDirectory()中,我们只返回CONTINUE

删除文件夹时,在D:/learning中,我们可以调用DeleteFileVisitor,如下所示:

Path directory = Paths.get("D:/learning");
DeleteFileVisitor deleteFileVisitor = new DeleteFileVisitor();
EnumSet opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS);

Files.walkFileTree(directory, opts, 
  Integer.MAX_VALUE, deleteFileVisitor);

通过将SearchFileVisitorDeleteFileVisitor组合,我们可以得到一个搜索删除应用。

复制文件夹

为了复制一个文件,我们可以使用Path copy​(Path source, Path target, CopyOption options) throws IOException方法。此方法使用指定如何执行复制的参数options将文件复制到目标文件。

通过将copy()方法与自定义FileVisitor相结合,我们可以复制整个文件夹(包括其所有内容)。本次定制FileVisitor存根代码如下:

public class CopyFileVisitor implements FileVisitor {

  private final Path copyFrom;
  private final Path copyTo;
  ...

  private static void copySubTree(
      Path copyFrom, Path copyTo) throws IOException {

    Files.copy(copyFrom, copyTo, 
      REPLACE_EXISTING, COPY_ATTRIBUTES);
  }
}

让我们看一下主要的检查点和复制文件夹的实现(注意,我们将通过复制任何可以复制的内容来进行操作,并避免抛出异常,但可以随意调整代码以满足您的需要):

  • 在从源文件夹复制任何文件之前,我们需要复制源文件夹本身。复制源文件夹(空或不空)将导致目标文件夹为空。这是用preVisitDirectory()方法完成的完美任务:
@Override
public FileVisitResult preVisitDirectory(
  Object dir, BasicFileAttributes attrs) throws IOException {

  Path newDir = copyTo.resolve(
    copyFrom.relativize((Path) dir));

  try {
    Files.copy((Path) dir, newDir, 
      REPLACE_EXISTING, COPY_ATTRIBUTES);
  } catch (IOException e) {
    System.err.println("Unable to create "
      + newDir + " [" + e + "]");

    return FileVisitResult.SKIP_SUBTREE;
  }

  return FileVisitResult.CONTINUE;
}
  • visitFile()方法是复制每个文件的最佳场所:
@Override
public FileVisitResult visitFile(
  Object file, BasicFileAttributes attrs) throws IOException {

  try {
    copySubTree((Path) file, copyTo.resolve(
      copyFrom.relativize((Path) file)));
  } catch (IOException e) {
    System.err.println("Unable to copy " 
      + copyFrom + " [" + e + "]");
  }

  return FileVisitResult.CONTINUE;
}
  • 或者,我们可以保留源目录的属性。只有将文件复制到postVisitDirectory()方法后才能完成(例如,保留上次修改的时间):
@Override
public FileVisitResult postVisitDirectory(
    Object dir, IOException ioe) throws IOException {

  Path newDir = copyTo.resolve(
    copyFrom.relativize((Path) dir));

  try {
    FileTime time = Files.getLastModifiedTime((Path) dir);
    Files.setLastModifiedTime(newDir, time);
  } catch (IOException e) {
    System.err.println("Unable to preserve 
      the time attribute to: " + newDir + " [" + e + "]");
  }

  return FileVisitResult.CONTINUE;
}
  • 如果无法访问文件,则调用visitFileFailed()。现在是检测循环链接并报告它们的好时机。通过以下链接(FOLLOW_LINKS),我们可以遇到文件树与父文件夹有循环链接的情况。这些病例通过visitFileFailed()中的FileSystemLoopException异常报告:
@Override
public FileVisitResult visitFileFailed(
    Object file, IOException ioe) throws IOException {

  if (ioe instanceof FileSystemLoopException) {
    System.err.println("Cycle was detected: " + (Path) file);
  } else {
    System.err.println("Error occured, unable to copy:"
      + (Path) file + " [" + ioe + "]");
  }

  return FileVisitResult.CONTINUE;
}

D:/learning/packt文件夹复制到D:/e-courses

Path copyFrom = Paths.get("D:/learning/packt");
Path copyTo = Paths.get("D:/e-courses");

CopyFileVisitor copyFileVisitor 
  = new CopyFileVisitor(copyFrom, copyTo);

EnumSet opts = EnumSet.of(FileVisitOption.FOLLOW_LINKS);

Files.walkFileTree(copyFrom, opts, Integer.MAX_VALUE, copyFileVisitor);

通过组合CopyFileVisitorDeleteFileVisitor,我们可以很容易地形成一个移动文件夹的应用。在本书附带的代码中,还有一个移动文件夹的完整示例。基于我们迄今为止积累的专业知识,代码应该是非常容易访问的,没有更多的细节。

在记录有关文件的信息(例如,处理异常的情况)时要注意,因为文件(例如,它们的名称、路径和属性)可能包含敏感信息,这些信息可能会被恶意利用。

JDK8,Files.walk()

从 JDK8 开始,Files类用两个walk()方法进行了丰富。这些方法返回一个由Path惰性填充的Stream。它通过使用给定的最大深度和选项遍历以给定起始文件为根的文件树来实现这一点:

public static Stream<Path> walk​(
  Path start, FileVisitOption...options) 
    throws IOException

public static Stream<Path> walk​(
  Path start, int maxDepth, FileVisitOption...options) 
    throws IOException

例如,让我们显示从D:/learning开始并以D:/learning/books/cdi开头的所有路径:

Path directory = Paths.get("D:/learning");

Stream<Path> streamOfPath = Files.walk(
  directory, FileVisitOption.FOLLOW_LINKS);

streamOfPath.filter(e -> e.startsWith("D:/learning/books/cdi"))
  .forEach(System.out::println);

现在,让我们计算一个文件夹的字节大小(例如,D:/learning):

long folderSize = Files.walk(directory)
  .filter(f -> f.toFile().isFile())
  .mapToLong(f -> f.toFile().length())
  .sum();

此方法为弱一致。它不会在迭代过程中冻结文件树。对文件树的潜在更新可能会反映出来,也可能不会反映出来。

135 监视路径

监视路径的变化只是可以通过 JDK7nio.2(低级的WatchServiceAPI)实现的线程安全目标之一。

简而言之,可以通过以下两个主要步骤来观察路径的变化:

  1. 为不同类型的事件类型注册要监视的文件夹。
  2. WatchService检测到注册的事件类型时,它在单独的线程中处理,因此监视服务不会被阻塞。

在 API 级别,起点是WatchService接口。对于不同的文件/操作系统,这个接口有不同的风格。

这个接口与两个主要类一起工作。它们一起提供了一种方便的方法,您可以实现这种方法来将监视功能添加到特定上下文(例如,文件系统):

  • Watchable:实现此接口的任何对象都是可观察对象,因此可以观察其变化(例如Path

  • StandardWatchEventKinds:这个类定义了标准的事件类型(这些是我们可以注册通知的事件类型:

    • ENTRY_CREATE:创建目录条目
    • ENTRY_DELETE:删除目录条目
    • ENTRY_MODIFY:目录条目已修改;被认为是修改的内容在某种程度上是特定于平台的,但实际上修改文件的内容应该始终触发此事件类型
    • OVERFLOW:一种特殊事件,表示事件可能已经丢失或丢弃

WatchService被称为观察者,我们说观察者观察可观察对象。在下面的示例中,WatchService将通过FileSystem类创建,并监视已注册的Path

监视文件夹的更改

让我们从一个桩方法开始,该方法获取应该监视其更改的文件夹的Path作为参数:

public void watchFolder(Path path) 
    throws IOException, InterruptedException {
  ...
}

当给定文件夹中出现ENTRY_CREATEENTRY_DELETEENTRY_MODIFY事件类型时,WatchService将通知我们。为此,我们需要遵循以下几个步骤:

  1. 创建WatchService以便我们可以监视文件系统这是通过FileSystem.newWatchService()完成的,如下所示:
WatchService watchService 
  = FileSystems.getDefault().newWatchService();
  1. 注册应通知的事件类型这是通过Watchable.register()完成的:
path.register(watchService, StandardWatchEventKinds.ENTRY_CREATE,
  StandardWatchEventKinds.ENTRY_MODIFY,
  StandardWatchEventKinds.ENTRY_DELETE);

对于每个可监视对象,我们接收一个注册令牌作为WatchKey实例(监视键)。我们在注册时收到这个监视键,但是每次触发事件时WatchService都返回相关的WatchKey

  1. 现在,我们需要等待传入事件。这是在无限循环中完成的(当事件发生时,观察者负责将相应的观察键排队等待以后检索,并将其状态更改为已发射
while (true) {
  // process the incoming event types
}
  1. 现在,我们需要检索监视键——检索监视键的方法至少有三种:
    • poll():返回队列中的下一个键并将其移除(或者,如果没有键,则返回null)。
    • poll​(long timeout, TimeUnit unit):返回队列中的下一个键并将其删除;如果没有键,则等待指定的超时并重试。如果键仍然不可用,则返回null
    • take():返回队列中的下一个键并将其删除;如果没有键,则等待某个键排队或无限循环停止:
WatchKey key = watchService.take();
  1. 接下来,我们需要检索监视键的未决事件。一个已发射状态的监视键至少有一个挂起事件,我们可以通过WatchKey.pollEvents()方法检索并删除某个监视键的所有事件(每个事件由一个WatchEvent实例表示):
for (WatchEvent<?> watchEvent : key.pollEvents()) {
  ...
}
  1. 然后,我们检索关于事件类型的信息。对于每个事件,我们可以获得不同的信息(例如,事件类型、出现次数和上下文特定的信息(例如,导致事件的文件名),这对于处理事件很有用):
Kind<?> kind = watchEvent.kind();
WatchEvent<Path> watchEventPath = (WatchEvent<Path>) watchEvent;
Path filename = watchEventPath.context();
  1. 接下来,我们重置监视键监视键的状态可以是就绪(创建时的初始状态)、已发射无效。一旦发出信号,一个监视键就会保持这样,直到我们调用reset()方法,尝试将其放回就绪状态,接受事件的状态。如果已发射就绪(恢复等待事件)转换成功,则reset()方法返回true;否则返回false,表示监视键可能无效。一个监视键如果不再处于活动状态,可能会处于无效状态(显式调用监视键close()方法、关闭监视程序、删除目录等都会导致不活动):
boolean valid = key.reset();

if (!valid) {
  break;
}

当有一个监视键处于无效状态时,就没有理由停留在无限循环中。只需调用break即可跳出循环。

  1. 最后,我们关闭监视器。具体调用WatchServiceclose()方法或依赖资源尝试,可实现如下:
try (WatchService watchService
    = FileSystems.getDefault().newWatchService()) {
  ...
}

本书附带的代码将所有这些代码片段粘在一个名为FolderWatcher的类中。结果将是一个观察者,它能够报告在指定路径上发生的创建、删除和修改事件。

为了观察路径,即D:/learning/packt,我们只调用watchFolder()方法:

Path path = Paths.get("D:/learning/packt");

FolderWatcher watcher = new FolderWatcher();
watcher.watchFolder(path);

运行应用将显示以下消息:

Watching: D:\learning\packt

现在,我们可以直接在此文件夹下创建、删除或修改文件,并检查通知。例如,如果我们简单地复制粘贴一个名为resources.txt的文件,那么输出如下:

ENTRY_CREATE -> resources.txt
ENTRY_MODIFY -> resources.txt

最后,不要忘记停止应用,因为它将无限期地运行(理论上)。

从这个应用开始,本书附带的源代码附带了另外两个应用。其中一个是视频捕获系统的模拟,而另一个是打印机托盘观察器的模拟。依靠我们在本节中积累的知识,理解这两个应用应该非常简单,无需进一步的细节。

136 流式传输文件内容

流式传输文件内容是一个可以通过 JDK8 使用Files.lines()BufferedReader.lines()方法解决的问题。

Stream<String> Files.lines​(Path path, Charset cs)将文件中的所有行读取为Stream。当流被消耗时,这种情况会缓慢发生。在终端流操作的执行过程中,不应该修改文件的内容;否则,结果是未定义的。

让我们看一个读取D:/learning/packt/resources.txt文件内容并将其显示在屏幕上的示例(注意,我们使用资源尝试运行代码,因此通过关闭流来关闭文件):

private static final String FILE_PATH 
  = "D:/learning/packt/resources.txt";
...
try (Stream<String> filesStream = Files.lines(
  Paths.get(FILE_PATH), StandardCharsets.UTF_8)) {

  filesStream.forEach(System.out::println);
} catch (IOException e) {
  // handle IOException if needed, otherwise remove the catch block
}

BufferedReader类中也有类似的无参数方法:

try (BufferedReader brStream = Files.newBufferedReader(
  Paths.get(FILE_PATH), StandardCharsets.UTF_8)) {

  brStream.lines().forEach(System.out::println);
} catch (IOException e) {
  // handle IOException if needed, otherwise remove the catch block
}

137 在文件树中搜索文件/文件夹

在文件树中搜索文件或文件夹是一项常见的任务,在很多情况下都需要这样做。多亏了 JDK8 和新的Files.find()方法,我们可以很容易地完成这个任务。

Files.find()方法返回一个Stream<Path>,其中惰性地填充了与提供的查找约束匹配的路径:

public static Stream<Path> find​(
  Path start,
  int maxDepth,
  BiPredicate<Path, ​BasicFileAttributes > matcher,
  FileVisitOption...options
) throws IOException

此方法作为walk()方法,因此它遍历当前文件树,从给定路径(start)开始,到达最大给定深度(maxDepth)。在当前文件树的迭代过程中,此方法应用给定的谓词(matcher)。通过这个谓词,我们指定最终流中的每个文件必须匹配的约束。或者,我们可以指定一组访问选项(options)。

Path startPath = Paths.get("D:/learning");

让我们看看一些示例,这些示例旨在阐明此方法的用法:

  • 找到以.properties扩展名结尾的所有文件,并遵循符号链接
Stream<Path> resultAsStream = Files.find(
  startPath,
  Integer.MAX_VALUE,
  (path, attr) -> path.toString().endsWith(".properties"),
  FileVisitOption.FOLLOW_LINKS
);
  • 查找所有以application开头的常规文件:
Stream<Path> resultAsStream = Files.find(
  startPath,
  Integer.MAX_VALUE,
  (path, attr) -> attr.isRegularFile() &amp;&amp;
  path.getFileName().toString().startsWith("application")
);
  • 查找 2019 年 3 月 16 日之后创建的所有目录:
Stream<Path> resultAsStream = Files.find(
  startPath,
  Integer.MAX_VALUE,
  (path, attr) -> attr.isDirectory() &amp;&amp;
    attr.creationTime().toInstant()
      .isAfter(LocalDate.of(2019, 3, 16).atStartOfDay()
        .toInstant(ZoneOffset.UTC))
);

如果我们喜欢将约束表示为表达式(例如,正则表达式),那么我们可以使用PathMatcher接口。这个接口附带了一个名为matches(Path path)的方法,它可以判断给定的路径是否匹配这个匹配器的模式。

FileSystem实现通过FileSystem.getPathMatcher(String syntaxPattern)支持 globregex 语法(也可能支持其他语法)。约束采用syntax:pattern的形式。

基于PathMatcher,我们可以编写能够覆盖广泛约束的辅助方法。例如,下面的辅助方法仅获取与给定约束相关的文件作为syntax:pattern

public static Stream<Path> fetchFilesMatching(Path root,
    String syntaxPattern) throws IOException {

  final PathMatcher matcher
    = root.getFileSystem().getPathMatcher(syntaxPattern);

  return Files.find(root, Integer.MAX_VALUE, (path, attr)
    -> matcher.matches(path) &amp;&amp; !attr.isDirectory());
}

通过 glob 语法查找所有 Java 文件可以实现如下:

Stream<Path> resultAsStream 
  = fetchFilesMatching(startPath, "glob:**/*.java");

如果我们只想列出当前文件夹中的文件(没有任何约束,只有一层深),那么我们可以使用Files.list()方法,如下例所示:

try (Stream<Path> allfiles = Files.list(startPath)) {
  ...
}

138 高效读写文本文件

在 Java 中,高效地读取文件需要选择正确的方法。为了更好地理解下面的示例,我们假设平台的默认字符集是 UTF-8。通过编程,可以通过Charset.defaultCharset()获取平台的默认字符集。

首先,我们需要从 Java 的角度区分原始二进制数据和文本文件。处理原始二进制数据是两个abstract类的工作,即InputStreamOutputStream。对于原始二进制数据的流文件,我们关注于一次读/写一个字节(8 位)的FileInputStreamFileOutputStream类。对于著名的二进制数据类型,我们也有专门的类(例如,音频文件应该通过AudioInputStream而不是FileInputStream进行处理)。

虽然这些类在处理原始二进制数据方面做得非常出色,但它们不适合处理文本文件,因为它们速度慢并且可能产生错误的输出。如果我们认为通过这些类流式传输文本文件意味着从文本文件中读取并处理每个字节(写入一个字节需要相同的繁琐流程),那么这一点就非常清楚了。此外,如果一个字符有超过 1 个字节,那么可能会看到一些奇怪的字符。换句话说,独立于字符集(例如,拉丁语、汉语等)对 8 位进行解码和编码可能产生意外的输出。

例如,假设我们有一首保存在 UTF-16 中的中国诗:

Path chineseFile = Paths.get("chinese.txt");

...

以下代码将不会按预期显示:

try (InputStream is = new FileInputStream(chineseFile.toString())) {

  int i;
  while ((i = is.read()) != -1) {
    System.out.print((char) i);
  }
}

所以,为了解决这个问题,我们应该指定适当的字符集。虽然InputStream对此没有支持,但我们可以依赖InputStreamReader(或OutputStreamReader)。此类是从原始字节流到字符流的桥梁,允许我们指定字符集:

try (InputStreamReader isr = new InputStreamReader(
    new FileInputStream(chineseFile.toFile()), 
      StandardCharsets.UTF_16)) {

  int i;
  while ((i = isr.read()) != -1) {
    System.out.print((char) i);
  }
}

事情已经回到正轨,但仍然很慢!现在,应用可以一次读取多个单字节(取决于字符集),并使用指定的字符集将它们解码为字符。但再多几个字节仍然很慢。

InputStreamReader是射线二进制数据流和字符流之间的桥梁。但是 Java 也提供了FileReader类。它的目标是消除由字符文件表示的字符流的桥接。

对于文本文件,我们有一个称为FileReader类(或FileWriter类)的专用类。这个类一次读取 2 或 4 个字节(取决于使用的字符集)。实际上,在 JDK11 之前,FileReader不支持显式字符集。它只是使用了平台的默认字符集。这对我们不利,因为以下代码不会产生预期的输出:

try (FileReader fr = new FileReader(chineseFile.toFile())) {

  int i;
  while ((i = fr.read()) != -1) {
    System.out.print((char) i);
  }
}

但从 JDK11 开始,FileReader类又增加了两个支持显式字符集的构造器:

  • FileReader​(File file, Charset charset)
  • FileReader​(String fileName, Charset charset)

这一次,我们可以覆盖前面的代码片段并获得预期的输出:

try (FileReader frch = new FileReader(
    chineseFile.toFile(), StandardCharsets.UTF_16)) {

  int i;
  while ((i = frch.read()) != -1) {
    System.out.print((char) i);
  }
}

一次读取 2 或 4 个字节仍然比读取 1 个字节好,但仍然很慢。此外,请注意,前面的解决方案使用一个int来存储检索到的char,我们需要显式地将其转换为char以显示它。基本上,从输入文件中检索到的char被转换成int,然后我们将其转换回char

这就是缓冲流进入场景的地方。想想当我们在线观看视频时会发生什么。当我们观看视频时,浏览器正在提前缓冲传入的字节。这样,我们就有了一个平稳的体验,因为我们可以看到缓冲区中的字节,避免了在网络传输过程中看到字节可能造成的中断:

同样的原理也用于类,例如用于原始二进制流的BufferedInputStreamBufferedOutputStream和用于字符流的BufferedReaderBufferedWriter。其主要思想是在处理之前对数据进行缓冲。这一次,FileReader将数据返回到BufferedReader直到它到达行的末尾(例如,\n\n\r)。BufferedReader使用 RAM 存储缓冲数据:

try (BufferedReader br = new BufferedReader(
    new FileReader(chineseFile.toFile(), StandardCharsets.UTF_16))) {

  String line;
  // keep buffering and print
  while ((line = br.readLine()) != null) {
    System.out.println(line);
  }
}

因此,我们不是一次读取 2 个字节,而是读取一整行,这要快得多。这是一种非常有效的读取文本文件的方法。

为了进一步优化,我们可以通过专用构造器设置缓冲区的大小。

注意,BufferedReader类知道如何在传入数据的上下文中创建和处理缓冲区,但与数据源无关。在我们的例子中,数据的来源是FileReader,它是一个文件,但是相同的BufferedReader可以缓冲来自不同来源的数据(例如,网络、文件、控制台、打印机、传感器等等)。最后,我们读取缓冲的内容。

前面的例子代表了在 Java 中读取文本文件的主要方法。从 JDK8 开始,添加了一组新的方法,使我们的生活更轻松。为了创建一个BufferedReader,我们也可以依赖Files.newBufferedReader​(Path path, Charset cs)

try (BufferedReader br = Files.newBufferedReader(
    chineseFile, StandardCharsets.UTF_16)) {

  String line;
  while ((line = br.readLine()) != null) {
    System.out.println(line);
  }
}

对于BufferedWriter,我们有Files.newBufferedWriter()。这些方法的优点是直接支持Path

要将文本文件的内容提取为Stream<T>,请查看“流式传输文件内容”部分中的问题。

另一种可能导致眼睛疲劳的有效解决方案如下:

try (BufferedReader br = new BufferedReader(new InputStreamReader(
    new FileInputStream(chineseFile.toFile()), 
      StandardCharsets.UTF_16))) {

  String line;
  while ((line = br.readLine()) != null) {
    System.out.println(line);
  }
}

现在,我们来谈谈如何将文本文件直接读入内存。

读取内存中的文本文件

Files类提供了两个方法,可以读取内存中的整个文本文件。其中之一是List<String> readAllLines​(Path path, Charset cs)

List<String> lines = Files.readAllLines(
  chineseFile, StandardCharsets.UTF_16);

此外,我们可以通过Files.readString​(Path path, Charset cs)阅读String中的全部内容:

String content = Files.readString(chineseFile, 
  StandardCharsets.UTF_16);

虽然这些方法对于相对较小的文件非常方便,但对于较大的文件来说并不是一个好的选择。试图在内存中获取大文件很容易导致OutOfMemoryError,显然,这会消耗大量内存。或者,对于大文件(例如 200GB),我们可以关注内存映射文件(MappedByteBufferMappedByteBuffer允许我们创建和修改巨大的文件,并将它们视为非常大的数组。它们看起来像是在记忆中,即使它们不是。一切都发生在本机级别:

// or use, Files.newByteChannel()
try (FileChannel fileChannel = (FileChannel.open(chineseFile,
    EnumSet.of(StandardOpenOption.READ)))) {

  MappedByteBuffer mbBuffer = fileChannel.map(
    FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

  if (mbBuffer != null) {
    String bufferContent 
      = StandardCharsets.UTF_16.decode(mbBuffer).toString();

    System.out.println(bufferContent);
    mbBuffer.clear();
  }
}

对于大文件,建议使用固定大小遍历缓冲区,如下所示:

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

try (FileChannel fileChannel = (FileChannel.open(chineseFile,
    EnumSet.of(StandardOpenOption.READ)))) {

  int position = 0;
  long length = fileChannel.size();

  while (position < length) {
    long remaining = length - position;
    int bytestomap = (int) Math.min(MAP_SIZE, remaining);

    MappedByteBuffer mbBuffer = fileChannel.map(
      MapMode.READ_ONLY, position, bytestomap);

    ... // do something with the current buffer

    position += bytestomap;
  }
}

JDK13 准备发布非易失性MappedByteBuffer。敬请期待!

写入文本文件

对于每个专用于读取文本文件的类/方法(例如,BufferedReaderreadString()),Java 提供其对应的用于写入文本文件的类/方法(例如,BufferedWriterwriteString())。下面是通过BufferedWriter写入文本文件的示例:

Path textFile = Paths.get("sample.txt");

try (BufferedWriter bw = Files.newBufferedWriter(
    textFile, StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
      StandardOpenOption.WRITE)) {
  bw.write("Lorem ipsum dolor sit amet, ... ");
  bw.newLine();
  bw.write("sed do eiusmod tempor incididunt ...");
}

Iterable写入文本文件的一种非常方便的方法是Files.write​(Path path, Iterable<? extends CharSequence> lines, Charset cs, OpenOption... options)。例如,让我们将列表的内容写入文本文件(列表中的每个元素都写在文件中的一行上):

List<String> linesToWrite = Arrays.asList("abc", "def", "ghi");
Path textFile = Paths.get("sample.txt");
Files.write(textFile, linesToWrite, StandardCharsets.UTF_8,
  StandardOpenOption.CREATE, StandardOpenOption.WRITE);

最后,要将一个String写入一个文件,我们可以使用Files.writeString​(Path path, CharSequence csq, OpenOption... options)方法:

Path textFile = Paths.get("sample.txt");

String lineToWrite = "Lorem ipsum dolor sit amet, ...";
Files.writeString(textFile, lineToWrite, StandardCharsets.UTF_8,
  StandardOpenOption.CREATE, StandardOpenOption.WRITE);

通过StandardOpenOption可以控制文件的打开方式。在前面的示例中,如果文件不存在(CREATE),则创建这些文件,并打开这些文件进行写访问(WRITE)。有许多其他选项可用(例如,APPENDDELETE_ON_CLOSE等)。

最后,通过MappedByteBuffer编写文本文件可以如下完成(这对于编写大型文本文件非常有用):

Path textFile = Paths.get("sample.txt");
CharBuffer cb = CharBuffer.wrap("Lorem ipsum dolor sit amet, ...");

try (FileChannel fileChannel = (FileChannel) Files.newByteChannel(
    textFile, EnumSet.of(StandardOpenOption.CREATE,
      StandardOpenOption.READ, StandardOpenOption.WRITE))) {

  MappedByteBuffer mbBuffer = fileChannel
    .map(FileChannel.MapMode.READ_WRITE, 0, cb.length());

  if (mbBuffer != null) {
    mbBuffer.put(StandardCharsets.UTF_8.encode(cb));
  }
}

139 高效读写二进制文件

在上一个问题“高效读写文本文件”中,我们讨论了缓冲流(为了清晰起见,请考虑在本问题之前读取该问题)。对于二进制文件也一样,因此我们可以直接跳到一些示例中。

让我们考虑以下二进制文件及其字节大小:

Path binaryFile = Paths.get(
  "build/classes/modern/challenge/Main.class");

int fileSize = (int) Files.readAttributes(
  binaryFile, BasicFileAttributes.class).size();

我们可以通过FileInputStream读取byte[]中的文件内容(这不使用缓冲):

final byte[] buffer = new byte[fileSize];
try (InputStream is = new FileInputStream(binaryFile.toString())) {

  int i;
  while ((i = is.read(buffer)) != -1) {
    System.out.print("\nReading ... ");
  }
}

然而,前面的例子不是很有效。当从该输入流将buffer.length字节读入字节数组时,可以通过BufferedInputStream实现高效率,如下所示:

final byte[] buffer = new byte[fileSize];

try (BufferedInputStream bis = new BufferedInputStream(
    new FileInputStream(binaryFile.toFile()))) {

  int i;
  while ((i = bis.read(buffer)) != -1) {
    System.out.print("\nReading ... " + i);
  }
}

也可通过Files.newInputStream()方法获得FileInputStream。这种方法的优点在于它直接支持Path

final byte[] buffer = new byte[fileSize];

try (BufferedInputStream bis = new BufferedInputStream(
    Files.newInputStream(binaryFile))) {

  int i;
  while ((i = bis.read(buffer)) != -1) {
    System.out.print("\nReading ... " + i);
  }
}

如果文件太大,无法放入文件大小的缓冲区,则最好通过具有固定大小(例如 512 字节)的较小缓冲区和read()样式来读取文件,如下所示:

  • read​(byte[] b)
  • read​(byte[] b, int off, int len)
  • readNBytes​(byte[] b, int off, int len)
  • readNBytes​(int len)

没有参数的read()方法将逐字节读取输入流。这是最低效的方法,尤其是在不使用缓冲的情况下。

或者,如果我们的目标是将输入流读取为字节数组,我们可以依赖于ByteArrayInputStream(它使用内部缓冲区,因此不需要使用BufferedInputStream):

final byte[] buffer = new byte[fileSize];

try (ByteArrayInputStream bais = new ByteArrayInputStream(buffer)) {

  int i;
  while ((i = bais.read(buffer)) != -1) {
    System.out.print("\nReading ... ");
  }
}

前面的方法非常适合原始二进制数据,但有时二进制文件包含某些数据(例如,intfloat等)。在这种情况下,DataInputStreamDataOutputStream为读写某些数据类型提供了方便的方法。假设我们有一个文件,data.bin,它包含float个数字。我们可以有效地阅读如下:

Path dataFile = Paths.get("data.bin");

try (DataInputStream dis = new DataInputStream(
    new BufferedInputStream(Files.newInputStream(dataFile)))) {

  while (dis.available() > 0) {
    float nr = dis.readFloat();
    System.out.println("Read: " + nr);
  }
}

这两个类只是 Java 提供的数据过滤器中的两个。有关所有受支持的数据过滤器的概述,请查看FilterInputStream的子类。此外,Scanner类是读取某些类型数据的好选择。有关更多信息,请查看“使用扫描器”部分中的问题。

现在,让我们看看如何将二进制文件直接读入内存。

将二进制文件读入内存

可以通过Files.readAllBytes()将整个二进制文件读入内存:

byte[] bytes = Files.readAllBytes(binaryFile);

类似的方法也存在于InputStream类中。

虽然这些方法对于相对较小的文件非常方便,但对于较大的文件来说并不是一个好的选择。尝试将大文件提取到内存中很容易出现 OOM 错误,而且显然会消耗大量内存。或者,对于大文件(例如 200GB),我们可以关注内存映射文件(MappedByteBufferMappedByteBuffer允许我们创建和修改巨大的文件,并将它们视为一个非常大的数组。他们看起来像是在记忆中,即使他们不是。一切都发生在本机级别:

try (FileChannel fileChannel = (FileChannel.open(binaryFile,
    EnumSet.of(StandardOpenOption.READ)))) {

  MappedByteBuffer mbBuffer = fileChannel.map(
    FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

  System.out.println("\nRead: " + mbBuffer.limit() + " bytes");
}

对于大型文件,建议在缓冲区内遍历固定大小的缓冲区,如下所示:

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

try (FileChannel fileChannel = FileChannel.open(
    binaryFile, StandardOpenOption.READ)) {

  int position = 0;
  long length = fileChannel.size();

  while (position < length) {
    long remaining = length - position;
    int bytestomap = (int) Math.min(MAP_SIZE, remaining);

    MappedByteBuffer mbBuffer = fileChannel.map(
      MapMode.READ_ONLY, position, bytestomap);

    ... // do something with the current buffer

    position += bytestomap;
  }
}

写入二进制文件

写入二进制文件的一种有效方法是使用BufferedOutputStream。例如,将byte[]写入文件可以如下完成:

final byte[] buffer...;
Path classFile = Paths.get(
  "build/classes/modern/challenge/Main.class");

try (BufferedOutputStream bos = newBufferedOutputStream(
    Files.newOutputStream(classFile, StandardOpenOption.CREATE,
      StandardOpenOption.WRITE))) {

  bos.write(buffer);
}

如果您正在逐字节写入数据,请使用write(int b)方法,如果您正在写入数据块,请使用write​(byte[] b, int off, int len)方法。

向文件写入byte[]的一种非常方便的方法是Files.write​(Path path, byte[] bytes, OpenOption... options)。例如,让我们编写前面缓冲区的内容:

Path classFile = Paths.get(
  "build/classes/modern/challenge/Main.class");

Files.write(classFile, buffer,
  StandardOpenOption.CREATE, StandardOpenOption.WRITE);

通过MappedByteBuffer写入二进制文件可以如下完成(这对于写入大型文本文件非常有用):

Path classFile = Paths.get(
  "build/classes/modern/challenge/Main.class");
try (FileChannel fileChannel = (FileChannel) Files.newByteChannel(
    classFile, EnumSet.of(StandardOpenOption.CREATE,
      StandardOpenOption.READ, StandardOpenOption.WRITE))) {

  MappedByteBuffer mbBuffer = fileChannel
    .map(FileChannel.MapMode.READ_WRITE, 0, buffer.length);

  if (mbBuffer != null) {
    mbBuffer.put(buffer);
  }
}

最后,如果我们正在写一段数据(不是原始的二进制数据),那么我们可以依赖于DataOutputStream。这个类为不同类型的数据提供了writeFoo()方法。例如,让我们将几个浮点值写入一个文件:

Path floatFile = Paths.get("float.bin");

try (DataOutputStream dis = new DataOutputStream(
    new BufferedOutputStream(Files.newOutputStream(floatFile)))) {
  dis.writeFloat(23.56f);
  dis.writeFloat(2.516f);
  dis.writeFloat(56.123f);
}

140 在大文件中搜索

搜索和计算文件中某个字符串的出现次数是一项常见的任务。尽可能快地实现这一点是一项强制性要求,尤其是当文件很大(例如 200GB)时。

注意,以下实现假设字符串11111中只出现一次,而不是两次。此外,前三个实现依赖于第 1 章“字符串、数字和数学”节的“在另一个字符串中对字符串进行计数”的帮助方法:

private static int countStringInString(String string, String tofind) {
  return string.split(Pattern.quote(tofind), -1).length - 1;
}

既然如此,让我们来看看解决这个问题的几种方法。

基于BufferedReader的解决方案

从前面的问题中我们已经知道,BufferedReader对于读取文本文件是非常有效的。因此,我们也可以用它来读取一个大文件。在读取时,对于通过BufferedReader.readLine()获得的每一行,我们需要通过countStringInString()计算所搜索字符串的出现次数:

public static int countOccurrences(Path path, String text, Charset ch)
    throws IOException {

  int count = 0;

  try (BufferedReader br = Files.newBufferedReader(path, ch)) {
    String line;
    while ((line = br.readLine()) != null) {
      count += countStringInString(line, text);
    }
  }

  return count;
}

基于Files.readAllLines()的解决方案

如果内存(RAM)对我们来说不是问题,那么我们可以尝试将整个文件读入内存(通过Files.readAllLines()并从那里处理它。将整个文件放在内存中支持并行处理。因此,如果我们的硬件可以通过并行处理突出显示,那么我们可以尝试依赖parallelStream(),如下所示:

public static int countOccurrences(Path path, String text, Charset ch)
    throws IOException {

  return Files.readAllLines(path, ch).parallelStream()
    .mapToInt((p) -> countStringInString(p, text))
    .sum();
}

如果parallelStream()没有任何好处,那么我们可以简单地切换到stream()。这只是一个基准问题。

基于Files.lines()的解决方案

我们也可以尝试通过Files.lines()利用流。这一次,我们将文件作为一个懒惰的Stream<String>来获取。如果我们可以利用并行处理(基准测试显示出更好的性能),那么通过调用parallel()方法来并行化Stream<String>就非常简单了:

public static int countOccurrences(Path path, String text, Charset ch)
    throws IOException {

  return Files.lines(path, ch).parallel()
    .mapToInt((p) -> countStringInString(p, text))
    .sum();
}

基于扫描器的解决方案

从 JDK9 开始,Scanner类附带了一个方法,该方法返回分隔符分隔的标记流Stream<String> tokens()。如果我们将要搜索的文本作为Scanner的分隔符,并对tokens()返回的Stream的条目进行计数,则得到正确的结果:

public static long countOccurrences(
  Path path, String text, Charset ch) throws IOException {

  long count;

  try (Scanner scanner = new Scanner(path, ch)
      .useDelimiter(Pattern.quote(text))) {

    count = scanner.tokens().count() - 1;
  }

  return count;
}

JDK10 中添加了支持显式字符集的扫描器构造器。

基于MappedByteBuffer的解决方案

我们将在这里讨论的最后一个解决方案是基于 JavaNIO.2、MappedByteBufferFileChannel的。此解决方案从给定文件上的一个FileChannel打开一个内存映射字节缓冲区(MappedByteBuffer)。我们遍历提取的字节缓冲区并查找与搜索字符串的匹配(该字符串被转换为一个byte[]并逐字节进行搜索)。

对于小文件,将整个文件加载到内存中会更快。对于大型/大型文件,以块(例如,5 MB 的块)的形式加载和处理文件会更快。一旦我们加载了一个块,我们就必须计算所搜索字符串的出现次数。我们存储结果并将其传递给下一个数据块。我们重复这个过程,直到遍历了整个文件。

让我们看一下这个实现的核心行(看一下与本书捆绑在一起的源代码以获得完整的代码):

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

public static int countOccurrences(Path path, String text)
                                          throws IOException {

  final byte[] texttofind = text.getBytes(StandardCharsets.UTF_8);
  int count = 0;

  try (FileChannel fileChannel = FileChannel.open(path,
                                   StandardOpenOption.READ)) {
    int position = 0;
    long length = fileChannel.size();

    while (position < length) {
      long remaining = length - position;
      int bytestomap = (int) Math.min(MAP_SIZE, remaining);

      MappedByteBuffer mbBuffer = fileChannel.map(
        MapMode.READ_ONLY, position, bytestomap);

      int limit = mbBuffer.limit();
      int lastSpace = -1;
      int firstChar = -1;

      while (mbBuffer.hasRemaining()) {        
        // spaghetti code omitted for brevity
        ...
      }
    }
  }

  return count;
}

这个解决方案非常快,因为文件直接从操作系统的内存中读取,而不必加载到 JVM 中。这些操作在本机级别进行,称为操作系统级别。请注意,此实现仅适用于 UTF-8 字符集,但也可以适用于其他字符集。

141 将 JSON/CSV 文件读取为对象

如今,JSON 和 CSV 文件无处不在。读取(反序列化)JSON/CSV 文件可能是一项日常任务,通常位于业务逻辑之前。编写(序列化)JSON/CSV 文件也是一项常见的任务,通常发生在业务逻辑的末尾。在读写这些文件之间,应用将数据用作对象。

将 JSON 文件读/写为对象

让我们从三个文本文件开始,它们代表典型的类似 JSON 的映射:

melons_raw.json中,每行有一个 JSON 条目。每一行都是一段独立于前一行的 JSON,但具有相同的模式。在melons_array.json中,我们有一个 JSON 数组,而在melons_map.json中,我们有一个非常适合 JavaMap的 JSON 数组。

对于这些文件中的每一个,我们都有一个Path,如下所示:

Path pathArray = Paths.get("melons_array.json");
Path pathMap = Paths.get("melons_map.json");
Path pathRaw = Paths.get("melons_raw.json");

现在,让我们看看三个专用库,它们将这些文件的内容作为Melon实例读取:

public class Melon {

  private String type;
  private int weight;

  // getters and setters omitted for brevity
}

使用 JSON-B

JavaEE8 附带了一个类似 JAXB 的声明性 JSON 绑定,称为 JSON-B(JSR-367)。JSON-B 与 JAXB 和其他 JavaEE/SE API 保持一致。JakartaEE 将 JavaEE8 JSON-P/B 提升到了一个新的层次。其 API 通过javax.json.bind.Jsonbjavax.json.bind.JsonbBuilder类公开:

Jsonb jsonb = JsonbBuilder.create();

对于反序列化,我们使用Jsonb.fromJson(),而对于序列化,我们使用Jsonb.toJson()

  • 让我们把melons_array.json读作MelonArray
Melon[] melonsArray = jsonb.fromJson(Files.newBufferedReader(
  pathArray, StandardCharsets.UTF_8), Melon[].class);
  • 让我们把melons_array.json读作MelonList
List<Melon> melonsList 
  = jsonb.fromJson(Files.newBufferedReader(
    pathArray, StandardCharsets.UTF_8), ArrayList.class);
  • 让我们把melons_map.json读作MelonMap
Map<String, Melon> melonsMap 
  = jsonb.fromJson(Files.newBufferedReader(
    pathMap, StandardCharsets.UTF_8), HashMap.class);
  • 让我们把melons_raw.json逐行读成Map
Map<String, String> stringMap = new HashMap<>();

try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    stringMap = jsonb.fromJson(line, HashMap.class);
    System.out.println("Current map is: " + stringMap);
  }
}
  • 让我们把melons_raw.json逐行读成Melon
try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    Melon melon = jsonb.fromJson(line, Melon.class);
    System.out.println("Current melon is: " + melon);
  }
}
  • 让我们将一个对象写入一个 JSON 文件(melons_output.json):
Path path = Paths.get("melons_output.json");

jsonb.toJson(melonsMap, Files.newBufferedWriter(path,
  StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
    StandardOpenOption.WRITE));

使用 Jackson

Jackson 是一个流行且快速的库,专门用于处理(序列化/反序列化)JSON 数据。Jackson API 依赖于com.fasterxml.jackson.databind.ObjectMapper。让我们再看一次前面的例子,但这次使用的是 Jackson:

ObjectMapper mapper = new ObjectMapper();

反序列化使用ObjectMapper.readValue(),序列化使用ObjectMapper.writeValue()

  • 让我们把melons_array.json读作MelonArray
Melon[] melonsArray 
  = mapper.readValue(Files.newBufferedReader(
    pathArray, StandardCharsets.UTF_8), Melon[].class);
  • 让我们把melons_array.json读作MelonList
List<Melon> melonsList 
  = mapper.readValue(Files.newBufferedReader(
    pathArray, StandardCharsets.UTF_8), ArrayList.class);
  • 让我们把melons_map.json读作MelonMap
Map<String, Melon> melonsMap 
  = mapper.readValue(Files.newBufferedReader(
    pathMap, StandardCharsets.UTF_8), HashMap.class);
  • 让我们把melons_raw.json逐行读成Map
Map<String, String> stringMap = new HashMap<>();

try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    stringMap = mapper.readValue(line, HashMap.class);
    System.out.println("Current map is: " + stringMap);
  }
}
  • 让我们把melons_raw.json逐行读成Melon
try (BufferedReader br = Files.newBufferedReader(
    pathRaw, StandardCharsets.UTF_8)) {

  String line;

  while ((line = br.readLine()) != null) {
    Melon melon = mapper.readValue(line, Melon.class);
    System.out.println("Current melon is: " + melon);
  }
}
  • 让我们将一个对象写入一个 JSON 文件(melons_output.json):
Path path = Paths.get("melons_output.json");

mapper.writeValue(Files.newBufferedWriter(path, 
  StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
    StandardOpenOption.WRITE), melonsMap);

使用 Gson

Gson 是另一个专门用于处理(序列化/反序列化)JSON 数据的简单库。在 Maven 项目中,它可以作为依赖项添加到pom.xml。它的 API 依赖于类名com.google.gson.Gson。本书附带的代码提供了一组示例。

将 CSV 文件作为对象读取

最简单的 CSV 文件类似于下图中的文件(用逗号分隔的数据行):

反序列化这种 CSV 文件的简单而有效的解决方案依赖于BufferedReaderString.split()方法。我们可以通过BufferedReader.readLine()读取文件中的每一行,并通过Spring.split()用逗号分隔符将其拆分。结果(每行内容)可以存储在List<String>中。最终结果为List<List<String>>,如下所示:

public static List<List<String>> readAsObject(
    Path path, Charset cs, String delimiter) throws IOException {

  List<List<String>> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;

    while ((line = br.readLine()) != null) {
      String[] values = line.split(delimiter);
      content.add(Arrays.asList(values));
    }
  }

  return content;
}

如果 CSV 数据有 POJO 对应项(例如,我们的 CSV 是序列化一堆Melon实例的结果),那么它可以反序列化,如下例所示:

public static List<Melon> readAsMelon(
    Path path, Charset cs, String delimiter) throws IOException {

  List<Melon> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;

    while ((line = br.readLine()) != null) {
      String[] values = line.split(Pattern.quote(delimiter));
      content.add(new Melon(values[0], Integer.valueOf(values[1])));
    }
  }

  return content;
}

对于复杂的 CSV 文件,建议使用专用库(例如 OpenCSV、ApacheCommons CSV、Super CSV 等)。

142 使用临时文件/文件夹

JavaNIO.2API 支持使用临时文件夹/文件。例如,我们可以很容易地找到临时文件夹/文件的默认位置,如下所示:

String defaultBaseDir = System.getProperty("java.io.tmpdir");

通常,在 Windows 中,默认的临时文件夹是C:\Temp, %Windows%\Temp,或者是Local Settings\Temp中每个用户的临时目录(这个位置通常通过TEMP环境变量控制)。在 Linux/Unix 中,全局临时目录是/tmp/var/tmp。前一行代码将返回默认位置,具体取决于操作系统。

在下一节中,我们将学习如何创建临时文件夹/文件。

创建临时文件夹/文件

使用Path createTempDirectory​(Path dir, String prefix, FileAttribute<?>... attrs)可以创建临时文件夹。这是Files类中的static方法,可以按如下方式使用:

  • 让我们在操作系统的默认位置创建一个没有前缀的临时文件夹:
// C:\Users\Anghel\AppData\Local\Temp\8083202661590940905
Path tmpNoPrefix = Files.createTempDirectory(null);
  • 让我们在操作系统的默认位置创建一个带有自定义前缀的临时文件夹:
// C:\Users\Anghel\AppData\Local\Temp\logs_5825861687219258744
String customDirPrefix = "logs_";
Path tmpCustomPrefix 
  = Files.createTempDirectory(customDirPrefix);
  • 让我们在自定义位置创建一个带有自定义前缀的临时文件夹:
// D:\tmp\logs_10153083118282372419
Path customBaseDir 
  = FileSystems.getDefault().getPath("D:/tmp");
String customDirPrefix = "logs_";
Path tmpCustomLocationAndPrefix 
  = Files.createTempDirectory(customBaseDir, customDirPrefix);

创建临时文件可以通过Path createTempFile​(Path dir, String prefix, String suffix, FileAttribute<?>... attrs)完成。这是Files类中的static方法,可以按如下方式使用:

  • 让我们在操作系统的默认位置创建一个没有前缀和后缀的临时文件:
// C:\Users\Anghel\AppData\Local\Temp\16106384687161465188.tmp
Path tmpNoPrefixSuffix = Files.createTempFile(null, null);
  • 让我们在操作系统的默认位置创建一个带有自定义前缀和后缀的临时文件:
// C:\Users\Anghel\AppData\Local\Temp\log_402507375350226.txt
String customFilePrefix = "log_";
String customFileSuffix = ".txt";
Path tmpCustomPrefixAndSuffix 
  = Files.createTempFile(customFilePrefix, customFileSuffix);
  • 让我们在自定义位置创建一个带有自定义前缀和后缀的临时文件:
// D:\tmp\log_13299365648984256372.txt
Path customBaseDir 
  = FileSystems.getDefault().getPath("D:/tmp");
String customFilePrefix = "log_";
String customFileSuffix = ".txt";
Path tmpCustomLocationPrefixSuffix = Files.createTempFile(
  customBaseDir, customFilePrefix, customFileSuffix);

在下面的部分中,我们将研究删除临时文件夹/文件的不同方法。

通过关闭挂钩删除临时文件夹/文件

删除临时文件夹/文件是一项可以由操作系统或专用工具完成的任务。然而,有时,我们需要通过编程来控制这一点,并基于不同的设计考虑删除一个文件夹/文件。

这个问题的解决依赖于关闭挂钩机制,可以通过Runtime.getRuntime().addShutdownHook()方法来实现。当我们需要在 JVM 关闭之前完成某些任务(例如,清理任务)时,这种机制非常有用。它被实现为一个 Java 线程,当 JVM 在关闭时执行关闭挂钩时调用其run()方法。如下代码所示:

Path customBaseDir = FileSystems.getDefault().getPath("D:/tmp");
String customDirPrefix = "logs_";
String customFilePrefix = "log_";
String customFileSuffix = ".txt";

try {
  Path tmpDir = Files.createTempDirectory(
    customBaseDir, customDirPrefix);
  Path tmpFile1 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);
  Path tmpFile2 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);

  Runtime.getRuntime().addShutdownHook(new Thread() {
    @Override
    public void run() {
      try (DirectoryStream<Path> ds 
          = Files.newDirectoryStream(tmpDir)) {
        for (Path file: ds) {
          Files.delete(file);
        }

        Files.delete(tmpDir);
      } catch (IOException e) {
        ...
      }
    }
  });

  //simulate some operations with temp file until delete it
  Thread.sleep(10000);
} catch (IOException | InterruptedException e) {
  ...
}

在异常/强制终止的情况下(例如 JVM 崩溃、触发终端操作等),将不执行关闭挂钩。当所有线程完成或调用System.exit(0)时,它运行。建议快速运行,因为如果出现问题(例如,操作系统关闭),可以在完成之前强制停止它们。通过编程,关闭挂钩只能由Runtime.halt()停止。

通过deleteOnExit()删除临时文件夹/文件

另一个删除临时文件夹/文件的解决方案依赖于File.deleteOnExit()方法。通过调用此方法,我们可以注册删除文件夹/文件。删除操作在 JVM 关闭时发生:

Path customBaseDir = FileSystems.getDefault().getPath("D:/tmp");
String customDirPrefix = "logs_";
String customFilePrefix = "log_";
String customFileSuffix = ".txt";

try {
  Path tmpDir = Files.createTempDirectory(
    customBaseDir, customDirPrefix);
  System.out.println("Created temp folder as: " + tmpDir);
  Path tmpFile1 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);
  Path tmpFile2 = Files.createTempFile(
    tmpDir, customFilePrefix, customFileSuffix);

  try (DirectoryStream<Path> ds = Files.newDirectoryStream(tmpDir)) {
    tmpDir.toFile().deleteOnExit();

    for (Path file: ds) {
      file.toFile().deleteOnExit();
    }
  } catch (IOException e) {
    ...
  }

  // simulate some operations with temp file until delete it
  Thread.sleep(10000);
} catch (IOException | InterruptedException e) {
  ...
}

当应用管理少量临时文件夹/文件时,建议仅依赖此方法(deleteOnExit())。此方法可能会消耗大量内存(它会为每个注册删除的临时资源消耗内存),并且在 JVM 终止之前,此内存可能不会被释放。请注意,因为需要调用此方法才能注册每个临时资源,而删除的顺序与注册的顺序相反(例如,我们必须先注册临时文件夹,然后再注册其内容)。

通过DELETE_ON_CLOSE删除临时文件

当涉及到删除临时文件时,另一个解决方案依赖于StandardOpenOption.DELETE_ON_CLOSE(这会在流关闭时删除文件)。例如,下面的代码通过createTempFile()方法创建一个临时文件,并为其打开一个缓冲的写入器流,其中明确指定了DELETE_ON_CLOSE

Path customBaseDir = FileSystems.getDefault().getPath("D:/tmp");
String customFilePrefix = "log_";
String customFileSuffix = ".txt";
Path tmpFile = null;

try {
  tmpFile = Files.createTempFile(
    customBaseDir, customFilePrefix, customFileSuffix);
} catch (IOException e) {
  ...
}

try (BufferedWriter bw = Files.newBufferedWriter(tmpFile,
    StandardCharsets.UTF_8, StandardOpenOption.DELETE_ON_CLOSE)) {

  //simulate some operations with temp file until delete it
  Thread.sleep(10000);
} catch (IOException | InterruptedException e) {
  ...
}

此解决方案可用于任何文件。它不是针对临时资源的。

143 过滤文件

Path中过滤文件是一项非常常见的任务。例如,我们可能只需要特定类型的文件、具有特定名称模式的文件、今天修改的文件等等。

通过Files.newDirectoryStream()过滤

不需要任何类型的过滤器,我们可以通过Files.newDirectoryStream(Path dir)方法轻松地循环文件夹的内容(一层深)。此方法返回一个DirectoryStream<Path>,这是一个对象,我们可以使用它来迭代目录中的条目:

Path path = Paths.get("D:/learning/books/spring");

try (DirectoryStream<Path> ds = Files.newDirectoryStream(path)) {

  for (Path file: ds) {
    System.out.println(file.getFileName());
  }
}

如果我们想用过滤器丰富这段代码,那么我们至少有两种解决方案。一种解决方案依赖于另一种口味的newDirectoryStream()方法newDirectoryStream​(Path dir, String glob)。除了Path之外,该方法还使用 glob 语法接收一个过滤器。例如,我们可以在D:/learning/books/spring文件夹中过滤 PNG、JPG 和 BMP 类型的文件:

try (DirectoryStream<Path> ds =
    Files.newDirectoryStream(path, "*.{png,jpg,bmp}")) {

  for (Path file: ds) {
    System.out.println(file.getFileName());
  }
}

glob 语法不能再帮助我们时,是时候使用另一种风格的newDirectoryStream()来获得Filter,即newDirectoryStream​(Path dir, DirectoryStream.Filter<? super Path> filter)。首先,让我们为大于 10 MB 的文件定义一个过滤器:

DirectoryStream.Filter<Path> sizeFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    return (Files.size(path) > 1024 * 1024 * 10);
  }
};

我们也可以在函数式风格上做到这一点:

DirectoryStream.Filter<Path> sizeFilter 
  = p -> (Files.size(p) > 1024 * 1024 * 10);

现在,我们可以这样应用这个过滤器:

try (DirectoryStream<Path> ds =
    Files.newDirectoryStream(path, sizeFilter)) {

  for (Path file: ds) {
    System.out.println(file.getFileName() + " " +
      Files.readAttributes(file, BasicFileAttributes.class).size() 
        + " bytes");
  }
}

让我们再看几个可以用于此技术的过滤器:

  • 以下是文件夹的用户定义过滤器:
DirectoryStream.Filter<Path> folderFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    return (Files.isDirectory(path, NOFOLLOW_LINKS));
  }
};
  • 以下是今天修改的文件的用户定义过滤器:
DirectoryStream.Filter<Path> todayFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    FileTime lastModified = Files.readAttributes(path,
      BasicFileAttributes.class).lastModifiedTime();

    LocalDate lastModifiedDate = lastModified.toInstant()
      .atOffset(ZoneOffset.UTC).toLocalDate();
    LocalDate todayDate = Instant.now()
      .atOffset(ZoneOffset.UTC).toLocalDate();

    return lastModifiedDate.equals(todayDate);
  }
};
  • 以下是隐藏文件/文件夹的用户定义过滤器:
DirectoryStream.Filter<Path> hiddenFilter 
    = new DirectoryStream.Filter<>() {

  @Override
  public boolean accept(Path path) throws IOException {
    return (Files.isHidden(path));
  }
};

在下面的部分中,我们将研究过滤文件的不同方法。

144 发现两个文件之间的不匹配

此问题的解决方案是比较两个文件的内容(逐字节比较),直到发现第一个不匹配或达到 EOF。

让我们考虑以下四个文本文件:

只有前两个文件(file1.txtfile2.txt相同。任何其他比较都应显示至少存在一个不匹配。

一种解决方法是使用MappedByteBuffer。这个解决方案是超级快速和易于实现。我们只需打开两个FileChannels(每个文件一个),然后逐字节进行比较,直到找到第一个不匹配或 EOF。如果文件的字节长度不同,则我们假设文件不相同,并立即返回:

private static final int MAP_SIZE = 5242880; // 5 MB in bytes

public static boolean haveMismatches(Path p1, Path p2) 
    throws IOException {

  try (FileChannel channel1 = (FileChannel.open(p1,
      EnumSet.of(StandardOpenOption.READ)))) {

    try (FileChannel channel2 = (FileChannel.open(p2,
        EnumSet.of(StandardOpenOption.READ)))) {

      long length1 = channel1.size();
      long length2 = channel2.size();

      if (length1 != length2) {
        return true;
      }

      int position = 0;
      while (position < length1) {
        long remaining = length1 - position;
        int bytestomap = (int) Math.min(MAP_SIZE, remaining);

        MappedByteBuffer mbBuffer1 = channel1.map(
          MapMode.READ_ONLY, position, bytestomap);
        MappedByteBuffer mbBuffer2 = channel2.map(
          MapMode.READ_ONLY, position, bytestomap);

        while (mbBuffer1.hasRemaining()) {
          if (mbBuffer1.get() != mbBuffer2.get()) {
            return true;
          }
        }

        position += bytestomap;
      }
    }
  }

  return false;
}

JDK-13 准备发布非易失性MappedByteBuffers。敬请期待!

从 JDK12 开始,Files类通过一种新方法得到了丰富,该方法专门用于指出两个文件之间的不匹配。此方法具有以下签名:

public static long mismatch​(Path path, Path path2) throws IOException

此方法查找并返回两个文件内容中第一个不匹配字节的位置。如果没有错配,则返回-1

long mismatches12 = Files.mismatch(file1, file2); // -1
long mismatches13 = Files.mismatch(file1, file3); // 51
long mismatches14 = Files.mismatch(file1, file4); // 60

通过FilenameFilter过滤

FilenameFilter函数式接口也可以用来过滤文件夹中的文件。首先,我们需要定义一个过滤器(例如,下面是 PDF 类型文件的过滤器):

String[] files = path.toFile().list(new FilenameFilter() {

  @Override
  public boolean accept(File folder, String fileName) {
    return fileName.endsWith(".pdf");
  }
});

我们可以在函数式风格上做同样的事情:

FilenameFilter filter = (File folder, String fileName) 
  -> fileName.endsWith(".pdf");

让我们更简洁一点:

FilenameFilter filter = (f, n) -> n.endsWith(".pdf");

为了使用这个过滤器,我们需要将它传递给过载的File.list​(FilenameFilter filter)File.listFiles​(FilenameFilter filter)方法:

String[] files = path.toFile().list(filter);

文件数组将只包含 PDF 文件的名称。

为了将结果取为File[],我们应该调用listFiles()而不是list()

通过FileFilter过滤

FileFilter是另一个可以用来过滤文件和文件夹的函数式接口。例如,让我们只过滤文件夹:

File[] folders = path.toFile().listFiles(new FileFilter() {

  @Override
  public boolean accept(File file) {
    return file.isDirectory();
  }
});

我们可以在函数式风格上做同样的事情:

File[] folders = path.toFile().listFiles((File file) 
  -> file.isDirectory());

让我们更简洁一点:

File[] folders = path.toFile().listFiles(f -> f.isDirectory());

最后,我们可以通过成员引用:

File[] folders = path.toFile().listFiles(File::isDirectory);

145 循环字节缓冲区

JavaNIO.2API 附带了一个名为java.nio.ByteBuffer的字节缓冲区的实现。基本上,这是一个字节数组(byte[]),由一组专门用于操作该数组的方法包装(例如,get()put()等等)。循环缓冲区(循环缓冲区、环形缓冲区或循环队列)是端到端连接的固定大小的缓冲区。下图显示了循环队列的外观:

循环缓冲区依赖于预先分配的数组(预先分配的容量),但某些实现可能也需要调整大小的功能。元素写入/添加到后面(尾部),从前面删除/读取(头部),如下图所示:

对于主操作,即读(获取)和写(设置),循环缓冲区维护一个指针(读指针和写指针)。两个指针都围绕着缓冲区容量。我们可以找出有多少元素可以读取,有多少空闲的插槽可以随时写入。此操作发生在O(1)

循环字节缓冲区是字节的循环缓冲区;它可以是字符或其他类型。这正是我们要在这里实现的。我们可以从编写实现的存根开始,如下所示:

public class CircularByteBuffer {

  private int capacity;
  private byte[] buffer;
  private int readPointer;
  private int writePointer;
  private int available;

  CircularByteBuffer(int capacity) {
    this.capacity = capacity;
    buffer = new byte[capacity];
  }

  public synchronized int available() {
    return available;
  }

  public synchronized int capacity() {
    return capacity;
  }

  public synchronized int slots() {
    return capacity - available;
  }

  public synchronized void clear() {
    readPointer = 0;
    writePointer = 0;
    available = 0;
  }
  ...
}

现在,让我们集中精力放置(写入)新字节和读取(获取)现有字节。例如,容量为 8 的循环字节缓冲区可以表示为:

让我们看看每一步都发生了什么:

  1. 循环字节缓冲区为空,两个指针都指向插槽 0(第一个插槽)。
  2. 我们将hello对应的 5 个字节放在缓冲区中,readPointer保持不变,而writePointer指向插槽 5。
  3. 我们得到与h对应的字节,所以readPointer移动到插槽 1。
  4. 最后,我们尝试将world的字节放入缓冲区。这个字由 5 个字节组成,但在达到缓冲区容量之前,我们只有 4 个空闲插槽。这意味着我们只能写与world对应的字节。

现在,让我们看一下下图中的场景:

从左到右,步骤如下:

  1. 前两个步骤与前一个场景中的步骤相同。
  2. 我们得到了hell的字节。这将把readPointer移动到位置 4。
  3. 最后,我们将world的字节放入缓冲区。这一次,字放入缓冲区,writePointer移动到槽 2。

基于此流程,我们可以轻松实现一种方法,将一个字节放入缓冲区,另一个从缓冲区获取一个字节,如下所示:

public synchronized boolean put(int value) {
  if (available == capacity) {
    return false;
  }

  buffer[writePointer] = (byte) value;
  writePointer = (writePointer + 1) % capacity;
  available++;

  return true;
}

public synchronized int get() {
  if (available == 0) {
    return -1;
  }

  byte value = buffer[readPointer];
  readPointer = (readPointer + 1) % capacity;
  available--;

  return value;
}

如果我们检查 JavaNIO.2ByteBufferAPI,我们会注意到它公开了get()put()方法的几种风格。例如,我们应该能够将一个byte[]传递给get()方法,这个方法应该将一系列元素从缓冲区复制到这个byte[]中。从当前的readPointer开始从缓冲区读取元素,从指定的offset开始在给定的byte[]中写入元素。

下图显示了writePointer大于readPointer的情况:

在左边,我们正在读 3 个字节。这将readPointer从其初始插槽 1 移动到插槽 4。在右边,我们正在读取 4 个(或超过 4 个)字节。由于只有 4 个字节可用,readPointer从其初始插槽移动到与writePointer相同的插槽(插槽 5)。

现在,我们来分析一个例子,writePointer小于readPointer

在左边,我们正在读 3 个字节。这将readPointer从其初始插槽 6 移动到插槽 1。在右边,我们正在读取 4 个(或超过 4 个)字节。这会将readPointer从其初始插槽 6 移动到插槽 2(与writePointer相同的插槽)。

既然我们已经考虑到了这两个用例,我们可以编写一个get()方法,以便将一系列字节从缓冲区复制到给定的byte[],如下所示(该方法尝试从缓冲区读取len字节,然后将它们写入给定的byte[],从给定的offset开始):

public synchronized int get(byte[] dest, int offset, int len) {

  if (available == 0) {
    return 0;
  }

  int maxPointer = capacity;

  if (readPointer < writePointer) {
    maxPointer = writePointer;
  }

  int countBytes = Math.min(maxPointer - readPointer, len);
  System.arraycopy(buffer, readPointer, dest, offset, countBytes);
  readPointer = readPointer + countBytes;

  if (readPointer == capacity) {
    int remainingBytes = Math.min(len - countBytes, writePointer);

    if (remainingBytes > 0) {
      System.arraycopy(buffer, 0, dest,
        offset + countBytes, remainingBytes);
      readPointer = remainingBytes;
      countBytes = countBytes + remainingBytes;
    } else {
      readPointer = 0;
    }
  }

  available = available - countBytes;

  return countBytes;
}

现在,让我们集中精力将给定的byte[]放入缓冲区。从指定的offset开始从给定的byte[]读取元素,并从当前的writePointer开始写入缓冲区。下图显示了writePointer大于readPointer的情况:

在左边,我们有缓冲区的初始状态。所以,readPointer指向插槽 2,writePointer指向插槽 5。在写入 4 个字节(右侧)之后,我们可以看到,readPointer没有受到影响,writePointer指向插槽 1。

另一个用例假设readPointer大于writePointer

在左边,我们有缓冲区的初始状态。所以,readPointer指向插槽 4,writePointer指向插槽 2。在写入 4 个字节(右侧)之后,我们可以看到,readPointer没有受到影响,writePointer指向插槽 4。请注意,只有两个字节被成功写入。这是因为我们在写入所有 4 个字节之前已经达到了缓冲区的最大容量。

既然我们已经考虑到了这两个用例,我们可以编写一个put()方法,以便将给定的byte[]中的一系列字节复制到缓冲区中,如下(该方法尝试从给定的offset开始从给定的byte[]读取len字节,并尝试从当前的writePointer开始将其写入缓冲区):

public synchronized int put(byte[] source, int offset, int len) {

  if (available == capacity) {
    return 0;
  }

  int maxPointer = capacity;

  if (writePointer < readPointer) {
    maxPointer = readPointer;
  }

  int countBytes = Math.min(maxPointer - writePointer, len);
  System.arraycopy(source, offset, buffer, writePointer, countBytes);
  writePointer = writePointer + countBytes;

  if (writePointer == capacity) {
    int remainingBytes = Math.min(len - countBytes, readPointer);

    if (remainingBytes > 0) {
      System.arraycopy(source, offset + countBytes,
        buffer, 0, remainingBytes);
      writePointer = remainingBytes;
      countBytes = countBytes + remainingBytes;
    } else {
      writePointer = 0;
    }
  }

  available = available + countBytes;

  return countBytes;
}

如前所述,有时需要调整缓冲区的大小。例如,我们可能希望通过简单地调用resize()方法将其大小增加一倍。基本上,这意味着将所有可用字节(元素)复制到一个容量加倍的新缓冲区中:

public synchronized void resize() {

  byte[] newBuffer = new byte[capacity * 2];

  if (readPointer < writePointer) {
    System.arraycopy(buffer, readPointer, newBuffer, 0, available);
  } else {
    int bytesToCopy = capacity - readPointer;
    System.arraycopy(buffer, readPointer, newBuffer, 0, bytesToCopy);
    System.arraycopy(buffer, 0, newBuffer, bytesToCopy, writePointer);
  }

  buffer = newBuffer;
  capacity = buffer.length;
  readPointer = 0;
  writePointer = available;
}

查看本书附带的源代码,看看它是如何完整工作的。

146 分词文件

文件中的内容并不总是以可以立即处理的方式接收,并且需要一些额外的步骤,以便为处理做好准备。通常,我们需要对文件进行标记,并从不同的数据结构(数组、列表、映射等)中提取信息。

例如,让我们考虑一个文件,clothes.txt

Path path = Paths.get("clothes.txt");

其内容如下:

Top|white\10/XXL&amp;Swimsuit|black\5/L
Coat|red\11/M&amp;Golden Jacket|yellow\12/XLDenim|Blue\22/M

此文件包含一些服装物品及其详细信息,这些物品以&amp;字符分隔。单一条款如下:

article name | color \ no. available items / size

这里,我们有几个分隔符(&amp;|\/)和一个非常具体的格式。

现在,让我们来看看几个解决方案,它们将从这个文件中提取信息并将其标记为一个List。我们将在工具类FileTokenizer中收集这些信息。

List中获取物品的一种解决方案依赖于String.split()方法。基本上,我们必须逐行读取文件并对每行应用String.split()。每行分词的结果通过List.addAll()方法收集在List中:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  String delimiterStr = Pattern.quote(delimiter);
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      String[] values = line.split(delimiterStr);
      content.addAll(Arrays.asList(values));
    }
  }

  return content;
}

使用&amp;分隔符调用此方法将产生以下输出:

[Top|white\10/XXL, Swimsuit|black\5/L, Coat|red\11/M, Golden Jacket|yellow\12/XL, Denim|Blue\22/M]

上述溶液的另一种风味可以依赖于Collectors.toList()而不是Arrays.asList()

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  String delimiterStr = Pattern.quote(delimiter);
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      content.addAll(Stream.of(line.split(delimiterStr))
        .collect(Collectors.toList()));
    }
  }

  return content;
}

或者,我们可以通过Files.lines()以惰性方式处理内容:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  try (Stream<String> lines = Files.lines(path, cs)) {

    return lines.map(l -> l.split(Pattern.quote(delimiter)))
      .flatMap(Arrays::stream)
      .collect(Collectors.toList());
  }
}

对于相对较小的文件,我们可以将其加载到内存中并进行相应的处理:

Files.readAllLines(path, cs).stream()
  .map(l -> l.split(Pattern.quote(delimiter)))
  .flatMap(Arrays::stream)
  .collect(Collectors.toList());

另一种解决方案可以依赖于 JDK8 的Pattern.splitAsStream()方法。此方法从给定的输入序列创建流。为了便于修改,这次我们通过Collectors.joining(";")收集结果列表:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  Pattern pattern = Pattern.compile(Pattern.quote(delimiter));
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      content.add(pattern.splitAsStream(line)
        .collect(Collectors.joining(";")));
    }
  }
  return content;
}

我们用&amp;分隔符调用这个方法:

List<String> tokens = FileTokenizer.get(
  path, StandardCharsets.UTF_8, "&amp;");

结果如下:

[Top|white\10/XXL;Swimsuit|black\5/L, Coat|red\11/M;Golden Jacket|yellow\12/XL, Denim|Blue\22/M]

到目前为止,提出的解决方案通过应用一个分隔符来获得文章列表。但有时,我们需要应用更多的分隔符。例如,假设我们希望获得以下输出(列表):

[Top, white, 10, XXL, Swimsuit, black, 5, L, Coat, red, 11, M, Golden Jacket, yellow, 12, XL, Denim, Blue, 22, M]

为了获得这个列表,我们必须应用几个分隔符(&amp;|\/。这可以通过使用String.split()并将基于逻辑OR运算符(x|y)的正则表达式传递给它来实现:

public static List<String> getWithMultipleDelimiters(
    Path path, Charset cs, String...delimiters) throws IOException {

  String[] escapedDelimiters = new String[delimiters.length];
  Arrays.setAll(escapedDelimiters, t -> Pattern.quote(delimiters[t]));
  String delimiterStr = String.join("|", escapedDelimiters);

  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      String[] values = line.split(delimiterStr);
      content.addAll(Arrays.asList(values));
    }
  }

  return content;
}

让我们用分隔符(&amp;|\/调用此方法以获得所需的结果:

List<String> tokens = FileTokenizer.getWithMultipleDelimiters(
  path, StandardCharsets.UTF_8, 
    new String[] {"&amp;", "|", "\\", "/"});

好的,到目前为止,很好!所有这些解决方案都基于String.split()Pattern.splitAsStream()。另一组解决方案可以依赖于StringTokenizer类(它在性能方面并不出色,所以请小心使用它)。此类可以对给定的字符串应用一个(或多个)分隔符,并公开控制它的两个主要方法,即hasMoreElements()nextToken()

public static List<String> get(Path path,
    Charset cs, String delimiter) throws IOException {

  StringTokenizer st;
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      st = new StringTokenizer(line, delimiter);
      while (st.hasMoreElements()) {
        content.add(st.nextToken());
      }
    }
  }

  return content;
}

也可与Collectors配合使用:

public static List<String> get(Path path, 
    Charset cs, String delimiter) throws IOException {

  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      content.addAll(Collections.list(
          new StringTokenizer(line, delimiter)).stream()
        .map(t -> (String) t)
        .collect(Collectors.toList()));
    }
  }

  return content;
}

如果我们使用//分隔多个分隔符,则可以使用多个分隔符:

public static List<String> getWithMultipleDelimiters(
    Path path, Charset cs, String...delimiters) throws IOException {

  String delimiterStr = String.join("//", delimiters);
  StringTokenizer st;
  List<String> content = new ArrayList<>();

  try (BufferedReader br = Files.newBufferedReader(path, cs)) {

    String line;
    while ((line = br.readLine()) != null) {
      st = new StringTokenizer(line, delimiterStr);
      while (st.hasMoreElements()) {
        content.add(st.nextToken());
      }
    }
  }

  return content;
}

为了获得更好的性能和正则表达式支持(即高灵活性),建议使用String.split()而不是StringTokenizer。从同一类别中,也考虑“使用扫描器”部分。

147 将格式化输出直接写入文件

假设我们有 10 个数字(整数和双精度)并且我们希望它们在一个文件中被很好地格式化(有缩进、对齐和一些小数,以保持可读性和有用性)。

在我们的第一次尝试中,我们像这样将它们写入文件(没有应用格式):

Path path = Paths.get("noformatter.txt");

try (BufferedWriter bw = Files.newBufferedWriter(path,
    StandardCharsets.UTF_8, StandardOpenOption.CREATE,
      StandardOpenOption.WRITE)) {

  for (int i = 0; i < 10; i++) {
    bw.write("| " + intValues[i] + " | " + doubleValues[i] + " | ");
    bw.newLine();
  }
}

前面代码的输出类似于下图左侧所示:

但是,我们希望得到上图右侧所示的结果。为了解决这个问题,我们需要使用String.format()方法。此方法允许我们将格式规则指定为符合以下模式的字符串:

%[flags][width][.precision]conversion-character

现在,让我们看看这个模式的每个组成部分是什么:

  • [flags]是可选的,包括修改输出的标准方法。通常,它们用于格式化整数和浮点数。
  • [width]是可选的,并设置输出的字段宽度(写入输出的最小字符数)。
  • [.precision]可选,指定浮点值的精度位数(或从String中提取的子串长度)。
  • conversion-character是强制的,它告诉我们参数的格式。最常用的转换字符如下:
    • s:用于格式化字符串
    • d:用于格式化十进制整数
    • f:用于格式化浮点数
    • t:用于格式化日期/时间值

作为行分隔符,我们可以使用%n

有了这些格式化规则的知识,我们可以得到如下所示(%6s用于整数,%.3f用于双精度):

Path path = Paths.get("withformatter.txt");

try (BufferedWriter bw = Files.newBufferedWriter(path,
    StandardCharsets.UTF_8, StandardOpenOption.CREATE, 
      StandardOpenOption.WRITE)) {

  for (int i = 0; i<10; i++) {
    bw.write(String.format("| %6s | %.3f |",
      intValues[i], doubleValues[i]));
    bw.newLine();
  }
}

可以通过Formatter类提供另一种解决方案。此类专用于格式化字符串,并使用与String.format()相同的格式化规则。它有一个format()方法,我们可以用它覆盖前面的代码片段:

Path path = Paths.get("withformatter.txt");

try (Formatter output = new Formatter(path.toFile())) {

  for (int i = 0; i < 10; i++) {
    output.format("| %6s | %.3f |%n", intValues[i], doubleValues[i]);
  }
}

只格式化整数的数字怎么样?

好吧,我们可以通过应用一个DecimalFormat和一个字符串格式化程序来获得它,如下所示:

Path path = Paths.get("withformatter.txt");
DecimalFormat formatter = new DecimalFormat("###,### bytes");

try (Formatter output = new Formatter(path.toFile())) {

 for (int i = 0; i < 10; i++) {
   output.format("%12s%n", formatter.format(intValues[i]));
 }
}

148 使用扫描器

Scanner公开了一个 API,用于解析字符串、文件、控制台等中的文本。解析是将给定的输入分词并根据需要返回它的过程(例如,整数、浮点、双精度等)。默认情况下,Scanner使用空格(默认分隔符)解析给定的输入,并通过一组nextFoo()方法(例如,next()nextLine()nextInt()nextDouble()等)公开令牌。

从同一类问题出发,也考虑“分词文件”部分。

例如,假设我们有一个文件(doubles.txt),其中包含由空格分隔的双数,如下图所示:

如果我们想获得这个文本作为双精度文本,那么我们可以读取它并依赖于一段意大利面代码来标记并将其转换为双精度文本。或者,我们可以依赖于Scanner及其nextDouble()方法,如下所示:

try (Scanner scanDoubles = new Scanner(
    Path.of("doubles.txt"), StandardCharsets.UTF_8)) {

  while (scanDoubles.hasNextDouble()) {
    double number = scanDoubles.nextDouble();
    System.out.println(number);
  }
}

上述代码的输出如下:

23.4556
1.23
...

但是,文件可能包含不同类型的混合信息。例如,下图中的文件(people.txt)包含由不同分隔符(逗号和分号)分隔的字符串和整数:

Scanner公开了一个名为useDelimiter()的方法。此方法采用StringPattern类型的参数,以指定应用作正则表达式的分隔符:

try (Scanner scanPeople = new Scanner(Path.of("people.txt"),
    StandardCharsets.UTF_8).useDelimiter(";|,")) {

  while (scanPeople.hasNextLine()) {
    System.out.println("Name: " + scanPeople.next().trim());
    System.out.println("Surname: " + scanPeople.next());
    System.out.println("Age: " + scanPeople.nextInt());
    System.out.println("City: " + scanPeople.next());
  }
}

使用此方法的输出如下:

Name: Matt
Surname: Kyle
Age: 23
City: San Francisco
...

从 JDK9 开始,Scanner公开了一个名为tokens()的新方法。此方法返回来自Scanner的分隔符分隔的令牌流。例如,我们可以用它来解析people.txt文件并在控制台上打印出来,如下所示:

try (Scanner scanPeople = new Scanner(Path.of("people.txt"),
    StandardCharsets.UTF_8).useDelimiter(";|,")) {

  scanPeople.tokens().forEach(t -> System.out.println(t.trim()));
}

使用上述方法的输出如下:

Matt
Kyle
23
San Francisco
...

或者,我们可以通过空格连接令牌:

try (Scanner scanPeople = new Scanner(Path.of("people.txt"),
    StandardCharsets.UTF_8).useDelimiter(";|,")) {

  String result = scanPeople.tokens()
    .map(t -> t.trim())
    .collect(Collectors.joining(" "));
}

在“大文件搜索”部分中,有一个示例说明如何使用此方法搜索文件中的某一段文本。

使用上述方法的输出如下:

Matt Kyle 23 San Francisco Darel Der 50 New York ...

tokens()方法方面,JDK9 还附带了一个名为findAll()的方法。这是一种非常方便的方法,用于查找所有与某个正则表达式相关的标记(以StringPattern形式提供)。此方法返回一个Stream<MatchResult>,可以这样使用:

try (Scanner sc = new Scanner(Path.of("people.txt"))) {

  Pattern pattern = Pattern.compile("4[0-9]");

  List<String> ages = sc.findAll(pattern)
    .map(MatchResult::group)
    .collect(Collectors.toList());

  System.out.println("Ages: " + ages);
}

前面的代码选择了所有表示 40-49 岁年龄的标记,即 40、43 和 43。

如果我们希望解析控制台中提供的输入,Scanner是一种方便的方法:

Scanner scanConsole = new Scanner(System.in);

String name = scanConsole.nextLine();
String surname = scanConsole.nextLine();
int age = scanConsole.nextInt();
// an int cannot include "\n" so we need
//the next line just to consume the "\n"
scanConsole.nextLine();
String city = scanConsole.nextLine();

注意,对于数字输入(通过nextInt()nextFloat()等读取),我们也需要使用换行符(当我们点击Enter时会出现这种情况)。基本上,Scanner在解析一个数字时不会获取这个字符,因此它将进入下一个标记。如果我们不通过添加一个nextLine()代码行来消耗它,那么从这一点开始,输入将变得不对齐,并导致InputMismatchException类型的异常或过早结束。 JDK10 中引入了支持字符集的Scanner构造器。

我们来看看ScannerBufferedReader的区别。

扫描器与BufferedReader

那么,我们应该使用Scanner还是BufferedReader?好吧,如果我们需要解析这个文件,那么Scanner就是最好的方法,否则BufferedReader更合适,对它们进行一个正面比较,会发现:

  • BufferedReaderScanner快,因为它不执行任何解析操作。
  • BufferedReader在阅读方面优于Scanner在句法分析方面优于BufferedReader
  • 默认情况下,BufferedReader使用 8KB 的缓冲区,Scanner使用 1KB 的缓冲区。
  • BufferedReader非常适合读取长字符串,而Scanner更适合于短输入。
  • BufferedReader同步,但Scanner不同步。
  • Scanner可以使用BufferedReader,反之则不行。如下代码所示:
try (Scanner scanDoubles = new Scanner(Files.newBufferedReader(
    Path.of("doubles.txt"), StandardCharsets.UTF_8))) { 
  ...
}

总结

我们已经到了本章的结尾,在这里我们讨论了各种特定于 I/O 的问题,从操作、行走和监视路径到流文件以及读/写文本和二进制文件的有效方法,我们已经讨论了很多。

从本章下载应用以查看结果和其他详细信息。