第十七章 文件

在丑陋的 Java I/O 编程方式诞生多年以后,Java终于简化了文件读写的基本操作。

这种”困难方式”的全部细节都在 Appendix: I/O Streams。如果你读过这个部分,就会认同 Java 设计者毫不在意他们的使用者的体验这一观念。打开并读取文件对于大多数编程语言来说是非常常用的,由于 I/O 糟糕的设计以至于 很少有人能够在不依赖其他参考代码的情况下完成打开文件的操作。

好像 Java 设计者终于意识到了 Java 使用者多年来的痛苦,在 Java7 中对此引入了巨大的改进。这些新元素被放在 java.nio.file 包下面,过去人们通常把 nio 中的 n 理解为 new 即新的 io,现在更应该当成是 non-blocking 非阻塞 io(io就是input/output输入/输出)。java.nio.file 库终于将 Java 文件操作带到与其他编程语言相同的水平。最重要的是 Java8 新增的 streams 与文件结合使得文件操作编程变得更加优雅。我们将看一下文件操作的两个基本组件:

  1. 文件或者目录的路径;
  2. 文件本身。

文件和目录路径

一个 Path 对象表示一个文件或者目录的路径,是一个跨操作系统(OS)和文件系统的抽象,目的是在构造路径时不必关注底层操作系统,代码可以在不进行修改的情况下运行在不同的操作系统上。java.nio.file.Paths 类包含一个重载方法 static get(),该方法接受一系列 String 字符串或一个统一资源标识符(URI)作为参数,并且进行转换返回一个 Path 对象:

  1. // files/PathInfo.java
  2. import java.nio.file.*;
  3. import java.net.URI;
  4. import java.io.File;
  5. import java.io.IOException;
  6. public class PathInfo {
  7. static void show(String id, Object p) {
  8. System.out.println(id + ": " + p);
  9. }
  10. static void info(Path p) {
  11. show("toString", p);
  12. show("Exists", Files.exists(p));
  13. show("RegularFile", Files.isRegularFile(p));
  14. show("Directory", Files.isDirectory(p));
  15. show("Absolute", p.isAbsolute());
  16. show("FileName", p.getFileName());
  17. show("Parent", p.getParent());
  18. show("Root", p.getRoot());
  19. System.out.println("******************");
  20. }
  21. public static void main(String[] args) {
  22. System.out.println(System.getProperty("os.name"));
  23. info(Paths.get("C:", "path", "to", "nowhere", "NoFile.txt"));
  24. Path p = Paths.get("PathInfo.java");
  25. info(p);
  26. Path ap = p.toAbsolutePath();
  27. info(ap);
  28. info(ap.getParent());
  29. try {
  30. info(p.toRealPath());
  31. } catch(IOException e) {
  32. System.out.println(e);
  33. }
  34. URI u = p.toUri();
  35. System.out.println("URI: " + u);
  36. Path puri = Paths.get(u);
  37. System.out.println(Files.exists(puri));
  38. File f = ap.toFile(); // Don't be fooled
  39. }
  40. }
  41. /* 输出:
  42. Windows 10
  43. toString: C:\path\to\nowhere\NoFile.txt
  44. Exists: false
  45. RegularFile: false
  46. Directory: false
  47. Absolute: true
  48. FileName: NoFile.txt
  49. Parent: C:\path\to\nowhere
  50. Root: C:\
  51. ******************
  52. toString: PathInfo.java
  53. Exists: true
  54. RegularFile: true
  55. Directory: false
  56. Absolute: false
  57. FileName: PathInfo.java
  58. Parent: null
  59. Root: null
  60. ******************
  61. toString: C:\Users\Bruce\Documents\GitHub\onjava\
  62. ExtractedExamples\files\PathInfo.java
  63. Exists: true
  64. RegularFile: true
  65. Directory: false
  66. Absolute: true
  67. FileName: PathInfo.java
  68. Parent: C:\Users\Bruce\Documents\GitHub\onjava\
  69. ExtractedExamples\files
  70. Root: C:\
  71. ******************
  72. toString: C:\Users\Bruce\Documents\GitHub\onjava\
  73. ExtractedExamples\files
  74. Exists: true
  75. RegularFile: false
  76. Directory: true
  77. Absolute: true
  78. FileName: files
  79. Parent: C:\Users\Bruce\Documents\GitHub\onjava\
  80. ExtractedExamples
  81. Root: C:\
  82. ******************
  83. toString: C:\Users\Bruce\Documents\GitHub\onjava\
  84. ExtractedExamples\files\PathInfo.java
  85. Exists: true
  86. RegularFile: true
  87. Directory: false
  88. Absolute: true
  89. FileName: PathInfo.java
  90. Parent: C:\Users\Bruce\Documents\GitHub\onjava\
  91. ExtractedExamples\files
  92. Root: C:\
  93. ******************
  94. URI: file:///C:/Users/Bruce/Documents/GitHub/onjava/
  95. ExtractedExamples/files/PathInfo.java
  96. true
  97. */

我已经在这一章第一个程序的 main() 方法添加了第一行用于展示操作系统的名称,因此你可以看到不同操作系统之间存在哪些差异。理想情况下,差别会相对较小,并且使用 / 或者 \ 路径分隔符进行分隔。你可以看到我运行在Windows 10 上的程序输出。

toString() 方法生成完整形式的路径,你可以看到 getFileName() 方法总是返回当前文件名。 通过使用 Files 工具类(我们接下来将会更多地使用它),可以测试一个文件是否存在,测试是否是一个”普通”文件还是一个目录等等。”Nofile.txt”这个示例展示我们描述的文件可能并不在指定的位置;这样可以允许你创建一个新的路径。”PathInfo.java”存在于当前目录中,最初它只是没有路径的文件名,但它仍然被检测为”存在”。一旦我们将其转换为绝对路径,我们将会得到一个从”C:”盘(因为我们是在Windows机器下进行测试)开始的完整路径,现在它也拥有一个父路径。“真实”路径的定义在文档中有点模糊,因为它取决于具体的文件系统。例如,如果文件名不区分大小写,即使路径由于大小写的缘故而不是完全相同,也可能得到肯定的匹配结果。在这样的平台上,toRealPath() 将返回实际情况下的 Path,并且还会删除任何冗余元素。

这里你会看到 URI 看起来只能用于描述文件,实际上 URI 可以用于描述更多的东西;通过 维基百科 可以了解更多细节。现在我们成功地将 URI 转为一个 Path 对象。

最后,你会在 Path 中看到一些有点欺骗的东西,这就是调用 toFile() 方法会生成一个 File 对象。听起来似乎可以得到一个类似文件的东西(毕竟被称为 File ),但是这个方法的存在仅仅是为了向后兼容。虽然看上去应该被称为”路径”,实际上却应该表示目录或者文件本身。这是个非常草率并且令人困惑的命名,但是由于 java.nio.file 的存在我们可以安全地忽略它的存在。

选取路径部分片段

Path 对象可以非常容易地生成路径的某一部分:

  1. // files/PartsOfPaths.java
  2. import java.nio.file.*;
  3. public class PartsOfPaths {
  4. public static void main(String[] args) {
  5. System.out.println(System.getProperty("os.name"));
  6. Path p = Paths.get("PartsOfPaths.java").toAbsolutePath();
  7. for(int i = 0; i < p.getNameCount(); i++)
  8. System.out.println(p.getName(i));
  9. System.out.println("ends with '.java': " +
  10. p.endsWith(".java"));
  11. for(Path pp : p) {
  12. System.out.print(pp + ": ");
  13. System.out.print(p.startsWith(pp) + " : ");
  14. System.out.println(p.endsWith(pp));
  15. }
  16. System.out.println("Starts with " + p.getRoot() + " " + p.startsWith(p.getRoot()));
  17. }
  18. }
  19. /* 输出:
  20. Windows 10
  21. Users
  22. Bruce
  23. Documents
  24. GitHub
  25. on-java
  26. ExtractedExamples
  27. files
  28. PartsOfPaths.java
  29. ends with '.java': false
  30. Users: false : false
  31. Bruce: false : false
  32. Documents: false : false
  33. GitHub: false : false
  34. on-java: false : false
  35. ExtractedExamples: false : false
  36. files: false : false
  37. PartsOfPaths.java: false : true
  38. Starts with C:\ true
  39. */

可以通过 getName() 来索引 Path 的各个部分,直到达到上限 getNameCount()Path 也实现了 Iterable 接口,因此我们也可以通过增强的 for-each 进行遍历。请注意,即使路径以 .java 结尾,使用 endsWith() 方法也会返回 false。这是因为使用 endsWith() 比较的是整个路径部分,而不会包含文件路径的后缀。通过使用 startsWith()endsWith() 也可以完成路径的遍历。但是我们可以看到,遍历 Path 对象并不包含根路径,只有使用 startsWith() 检测根路径时才会返回 true

路径分析

Files 工具类包含一系列完整的方法用于获得 Path 相关的信息。

  1. // files/PathAnalysis.java
  2. import java.nio.file.*;
  3. import java.io.IOException;
  4. public class PathAnalysis {
  5. static void say(String id, Object result) {
  6. System.out.print(id + ": ");
  7. System.out.println(result);
  8. }
  9. public static void main(String[] args) throws IOException {
  10. System.out.println(System.getProperty("os.name"));
  11. Path p = Paths.get("PathAnalysis.java").toAbsolutePath();
  12. say("Exists", Files.exists(p));
  13. say("Directory", Files.isDirectory(p));
  14. say("Executable", Files.isExecutable(p));
  15. say("Readable", Files.isReadable(p));
  16. say("RegularFile", Files.isRegularFile(p));
  17. say("Writable", Files.isWritable(p));
  18. say("notExists", Files.notExists(p));
  19. say("Hidden", Files.isHidden(p));
  20. say("size", Files.size(p));
  21. say("FileStore", Files.getFileStore(p));
  22. say("LastModified: ", Files.getLastModifiedTime(p));
  23. say("Owner", Files.getOwner(p));
  24. say("ContentType", Files.probeContentType(p));
  25. say("SymbolicLink", Files.isSymbolicLink(p));
  26. if(Files.isSymbolicLink(p))
  27. say("SymbolicLink", Files.readSymbolicLink(p));
  28. if(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"))
  29. say("PosixFilePermissions",
  30. Files.getPosixFilePermissions(p));
  31. }
  32. }
  33. /* 输出:
  34. Windows 10
  35. Exists: true
  36. Directory: false
  37. Executable: true
  38. Readable: true
  39. RegularFile: true
  40. Writable: true
  41. notExists: false
  42. Hidden: false
  43. size: 1631
  44. FileStore: SSD (C:)
  45. LastModified: : 2017-05-09T12:07:00.428366Z
  46. Owner: MINDVIEWTOSHIBA\Bruce (User)
  47. ContentType: null
  48. SymbolicLink: false
  49. */

在调用最后一个测试方法 getPosixFilePermissions() 之前我们需要确认一下当前文件系统是否支持 Posix 接口,否则会抛出运行时异常。

Paths的增减修改

我们必须能通过对 Path 对象增加或者删除一部分来构造一个新的 Path 对象。我们使用 relativize() 移除 Path 的根路径,使用 resolve() 添加 Path 的尾路径(不一定是“可发现”的名称)。

对于下面代码中的示例,我使用 relativize() 方法从所有的输出中移除根路径,部分原因是为了示范,部分原因是为了简化输出结果,这说明你可以使用该方法将绝对路径转为相对路径。 这个版本的代码中包含 id,以便于跟踪输出结果:

  1. // files/AddAndSubtractPaths.java
  2. import java.nio.file.*;
  3. import java.io.IOException;
  4. public class AddAndSubtractPaths {
  5. static Path base = Paths.get("..", "..", "..").toAbsolutePath().normalize();
  6. static void show(int id, Path result) {
  7. if(result.isAbsolute())
  8. System.out.println("(" + id + ")r " + base.relativize(result));
  9. else
  10. System.out.println("(" + id + ") " + result);
  11. try {
  12. System.out.println("RealPath: " + result.toRealPath());
  13. } catch(IOException e) {
  14. System.out.println(e);
  15. }
  16. }
  17. public static void main(String[] args) {
  18. System.out.println(System.getProperty("os.name"));
  19. System.out.println(base);
  20. Path p = Paths.get("AddAndSubtractPaths.java").toAbsolutePath();
  21. show(1, p);
  22. Path convoluted = p.getParent().getParent()
  23. .resolve("strings").resolve("..")
  24. .resolve(p.getParent().getFileName());
  25. show(2, convoluted);
  26. show(3, convoluted.normalize());
  27. Path p2 = Paths.get("..", "..");
  28. show(4, p2);
  29. show(5, p2.normalize());
  30. show(6, p2.toAbsolutePath().normalize());
  31. Path p3 = Paths.get(".").toAbsolutePath();
  32. Path p4 = p3.resolve(p2);
  33. show(7, p4);
  34. show(8, p4.normalize());
  35. Path p5 = Paths.get("").toAbsolutePath();
  36. show(9, p5);
  37. show(10, p5.resolveSibling("strings"));
  38. show(11, Paths.get("nonexistent"));
  39. }
  40. }
  41. /* 输出:
  42. Windows 10
  43. C:\Users\Bruce\Documents\GitHub
  44. (1)r onjava\
  45. ExtractedExamples\files\AddAndSubtractPaths.java
  46. RealPath: C:\Users\Bruce\Documents\GitHub\onjava\
  47. ExtractedExamples\files\AddAndSubtractPaths.java
  48. (2)r on-java\ExtractedExamples\strings\..\files
  49. RealPath: C:\Users\Bruce\Documents\GitHub\onjava\
  50. ExtractedExamples\files
  51. (3)r on-java\ExtractedExamples\files
  52. RealPath: C:\Users\Bruce\Documents\GitHub\onjava\
  53. ExtractedExamples\files
  54. (4) ..\..
  55. RealPath: C:\Users\Bruce\Documents\GitHub\on-java
  56. (5) ..\..
  57. RealPath: C:\Users\Bruce\Documents\GitHub\on-java
  58. (6)r on-java
  59. RealPath: C:\Users\Bruce\Documents\GitHub\on-java
  60. (7)r on-java\ExtractedExamples\files\.\..\..
  61. RealPath: C:\Users\Bruce\Documents\GitHub\on-java
  62. (8)r on-java
  63. RealPath: C:\Users\Bruce\Documents\GitHub\on-java
  64. (9)r on-java\ExtractedExamples\files
  65. RealPath: C:\Users\Bruce\Documents\GitHub\onjava\
  66. ExtractedExamples\files
  67. (10)r on-java\ExtractedExamples\strings
  68. RealPath: C:\Users\Bruce\Documents\GitHub\onjava\
  69. ExtractedExamples\strings
  70. (11) nonexistent
  71. java.nio.file.NoSuchFileException:
  72. C:\Users\Bruce\Documents\GitHub\onjava\
  73. ExtractedExamples\files\nonexistent
  74. */

我还为 toRealPath() 添加了更多的测试,这是为了扩展和规则化,防止路径不存在时抛出运行时异常。

目录

Files 工具类包含大部分我们需要的目录操作和文件操作方法。出于某种原因,它们没有包含删除目录树相关的方法,因此我们将实现并将其添加到 onjava 库中。

  1. // onjava/RmDir.java
  2. package onjava;
  3. import java.nio.file.*;
  4. import java.nio.file.attribute.BasicFileAttributes;
  5. import java.io.IOException;
  6. public class RmDir {
  7. public static void rmdir(Path dir) throws IOException {
  8. Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
  9. @Override
  10. public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
  11. Files.delete(file);
  12. return FileVisitResult.CONTINUE;
  13. }
  14. @Override
  15. public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
  16. Files.delete(dir);
  17. return FileVisitResult.CONTINUE;
  18. }
  19. });
  20. }
  21. }

删除目录树的方法实现依赖于 Files.walkFileTree(),”walking” 目录树意味着遍历每个子目录和文件。Visitor 设计模式提供了一种标准机制来访问集合中的每个对象,然后你需要提供在每个对象上执行的操作。 此操作的定义取决于实现的 FileVisitor 的四个抽象方法,包括:

  1. 1. **preVisitDirectory()**:在访问目录中条目之前在目录上运行。
  2. 2. **visitFile()**:运行目录中的每一个文件。
  3. 3. **visitFileFailed()**:调用无法访问的文件。
  4. 4. **postVisitDirectory()**:在访问目录中条目之后在目录上运行,包括所有的子目录。

为了简化,java.nio.file.SimpleFileVisitor 提供了所有方法的默认实现。这样,在我们的匿名内部类中,我们只需要重写非标准行为的方法:visitFile()postVisitDirectory() 实现删除文件和删除目录。两者都应该返回标志位决定是否继续访问(这样就可以继续访问,直到找到所需要的)。 作为探索目录操作的一部分,现在我们可以有条件地删除已存在的目录。在以下例子中,makeVariant() 接受基本目录测试,并通过旋转部件列表生成不同的子目录路径。这些旋转与路径分隔符 sep 使用 String.join() 贴在一起,然后返回一个 Path 对象。

  1. // files/Directories.java
  2. import java.util.*;
  3. import java.nio.file.*;
  4. import onjava.RmDir;
  5. public class Directories {
  6. static Path test = Paths.get("test");
  7. static String sep = FileSystems.getDefault().getSeparator();
  8. static List<String> parts = Arrays.asList("foo", "bar", "baz", "bag");
  9. static Path makeVariant() {
  10. Collections.rotate(parts, 1);
  11. return Paths.get("test", String.join(sep, parts));
  12. }
  13. static void refreshTestDir() throws Exception {
  14. if(Files.exists(test))
  15. RmDir.rmdir(test);
  16. if(!Files.exists(test))
  17. Files.createDirectory(test);
  18. }
  19. public static void main(String[] args) throws Exception {
  20. refreshTestDir();
  21. Files.createFile(test.resolve("Hello.txt"));
  22. Path variant = makeVariant();
  23. // Throws exception (too many levels):
  24. try {
  25. Files.createDirectory(variant);
  26. } catch(Exception e) {
  27. System.out.println("Nope, that doesn't work.");
  28. }
  29. populateTestDir();
  30. Path tempdir = Files.createTempDirectory(test, "DIR_");
  31. Files.createTempFile(tempdir, "pre", ".non");
  32. Files.newDirectoryStream(test).forEach(System.out::println);
  33. System.out.println("*********");
  34. Files.walk(test).forEach(System.out::println);
  35. }
  36. static void populateTestDir() throws Exception {
  37. for(int i = 0; i < parts.size(); i++) {
  38. Path variant = makeVariant();
  39. if(!Files.exists(variant)) {
  40. Files.createDirectories(variant);
  41. Files.copy(Paths.get("Directories.java"),
  42. variant.resolve("File.txt"));
  43. Files.createTempFile(variant, null, null);
  44. }
  45. }
  46. }
  47. }
  48. /* 输出:
  49. Nope, that doesn't work.
  50. test\bag
  51. test\bar
  52. test\baz
  53. test\DIR_5142667942049986036
  54. test\foo
  55. test\Hello.txt
  56. *********
  57. test
  58. test\bag
  59. test\bag\foo
  60. test\bag\foo\bar
  61. test\bag\foo\bar\baz
  62. test\bag\foo\bar\baz\8279660869874696036.tmp
  63. test\bag\foo\bar\baz\File.txt
  64. test\bar
  65. test\bar\baz
  66. test\bar\baz\bag
  67. test\bar\baz\bag\foo
  68. test\bar\baz\bag\foo\1274043134240426261.tmp
  69. test\bar\baz\bag\foo\File.txt
  70. test\baz
  71. test\baz\bag
  72. test\baz\bag\foo
  73. test\baz\bag\foo\bar
  74. test\baz\bag\foo\bar\6130572530014544105.tmp
  75. test\baz\bag\foo\bar\File.txt
  76. test\DIR_5142667942049986036
  77. test\DIR_5142667942049986036\pre7704286843227113253.non
  78. test\foo
  79. test\foo\bar
  80. test\foo\bar\baz
  81. test\foo\bar\baz\bag
  82. test\foo\bar\baz\bag\5412864507741775436.tmp
  83. test\foo\bar\baz\bag\File.txt
  84. test\Hello.txt
  85. */

首先,refreshTestDir() 用于检测 test 目录是否已经存在。若存在,则使用我们新工具类 rmdir() 删除其整个目录。检查是否 exists 是多余的,但我想说明一点,因为如果你对于已经存在的目录调用 createDirectory() 将会抛出异常。createFile() 使用参数 Path 创建一个空文件; resolve() 将文件名添加到 test Path 的末尾。

我们尝试使用 createDirectory() 来创建多级路径,但是这样会抛出异常,因为这个方法只能创建单级路径。我已经将 populateTestDir() 作为一个单独的方法,因为它将在后面的例子中被重用。对于每一个变量 variant,我们都能使用 createDirectories() 创建完整的目录路径,然后使用此文件的副本以不同的目标名称填充该终端目录。然后我们使用 createTempFile() 生成一个临时文件。

在调用 populateTestDir() 之后,我们在 test 目录下面创建一个临时目录。请注意,createTempDirectory() 只有名称的前缀选项。与 createTempFile() 不同,我们再次使用它将临时文件放入新的临时目录中。你可以从输出中看到,如果未指定后缀,它将默认使用”.tmp”作为后缀。

为了展示结果,我们首次使用看起来很有希望的 newDirectoryStream(),但事实证明这个方法只是返回 test 目录内容的 Stream 流,并没有更多的内容。要获取目录树的全部内容的流,请使用 Files.walk()

文件系统

为了完整起见,我们需要一种方法查找文件系统相关的其他信息。在这里,我们使用静态的 FileSystems 工具类获取”默认”的文件系统,但你同样也可以在 Path 对象上调用 getFileSystem() 以获取创建该 Path 的文件系统。你可以获得给定 URI 的文件系统,还可以构建新的文件系统(对于支持它的操作系统)。

  1. // files/FileSystemDemo.java
  2. import java.nio.file.*;
  3. public class FileSystemDemo {
  4. static void show(String id, Object o) {
  5. System.out.println(id + ": " + o);
  6. }
  7. public static void main(String[] args) {
  8. System.out.println(System.getProperty("os.name"));
  9. FileSystem fsys = FileSystems.getDefault();
  10. for(FileStore fs : fsys.getFileStores())
  11. show("File Store", fs);
  12. for(Path rd : fsys.getRootDirectories())
  13. show("Root Directory", rd);
  14. show("Separator", fsys.getSeparator());
  15. show("UserPrincipalLookupService",
  16. fsys.getUserPrincipalLookupService());
  17. show("isOpen", fsys.isOpen());
  18. show("isReadOnly", fsys.isReadOnly());
  19. show("FileSystemProvider", fsys.provider());
  20. show("File Attribute Views",
  21. fsys.supportedFileAttributeViews());
  22. }
  23. }
  24. /* 输出:
  25. Windows 10
  26. File Store: SSD (C:)
  27. Root Directory: C:\
  28. Root Directory: D:\
  29. Separator: \
  30. UserPrincipalLookupService:
  31. sun.nio.fs.WindowsFileSystem$LookupService$1@15db9742
  32. isOpen: true
  33. isReadOnly: false
  34. FileSystemProvider:
  35. sun.nio.fs.WindowsFileSystemProvider@6d06d69c
  36. File Attribute Views: [owner, dos, acl, basic, user]
  37. */

一个 FileSystem 对象也能生成 WatchServicePathMatcher 对象,将会在接下来两章中详细讲解。

路径监听

通过 WatchService 可以设置一个进程对目录中的更改做出响应。在这个例子中,delTxtFiles() 作为一个单独的任务执行,该任务将遍历整个目录并删除以 .txt 结尾的所有文件,WatchService 会对文件删除操作做出反应:

  1. // files/PathWatcher.java
  2. // {ExcludeFromGradle}
  3. import java.io.IOException;
  4. import java.nio.file.*;
  5. import static java.nio.file.StandardWatchEventKinds.*;
  6. import java.util.concurrent.*;
  7. public class PathWatcher {
  8. static Path test = Paths.get("test");
  9. static void delTxtFiles() {
  10. try {
  11. Files.walk(test)
  12. .filter(f ->
  13. f.toString()
  14. .endsWith(".txt"))
  15. .forEach(f -> {
  16. try {
  17. System.out.println("deleting " + f);
  18. Files.delete(f);
  19. } catch(IOException e) {
  20. throw new RuntimeException(e);
  21. }
  22. });
  23. } catch(IOException e) {
  24. throw new RuntimeException(e);
  25. }
  26. }
  27. public static void main(String[] args) throws Exception {
  28. Directories.refreshTestDir();
  29. Directories.populateTestDir();
  30. Files.createFile(test.resolve("Hello.txt"));
  31. WatchService watcher = FileSystems.getDefault().newWatchService();
  32. test.register(watcher, ENTRY_DELETE);
  33. Executors.newSingleThreadScheduledExecutor()
  34. .schedule(PathWatcher::delTxtFiles,
  35. 250, TimeUnit.MILLISECONDS);
  36. WatchKey key = watcher.take();
  37. for(WatchEvent evt : key.pollEvents()) {
  38. System.out.println("evt.context(): " + evt.context() +
  39. "\nevt.count(): " + evt.count() +
  40. "\nevt.kind(): " + evt.kind());
  41. System.exit(0);
  42. }
  43. }
  44. }
  45. /* Output:
  46. deleting test\bag\foo\bar\baz\File.txt
  47. deleting test\bar\baz\bag\foo\File.txt
  48. deleting test\baz\bag\foo\bar\File.txt
  49. deleting test\foo\bar\baz\bag\File.txt
  50. deleting test\Hello.txt
  51. evt.context(): Hello.txt
  52. evt.count(): 1
  53. evt.kind(): ENTRY_DELETE
  54. */

delTxtFiles() 中的 try 代码块看起来有些多余,因为它们捕获的是同一种类型的异常,外部的 try 语句似乎已经足够了。然而出于某种原因,Java 要求两者都必须存在(这也可能是一个 bug)。还要注意的是在 filter() 中,我们必须显式地使用 f.toString() 转为字符串,否则我们调用 endsWith() 将会与整个 Path 对象进行比较,而不是路径名称字符串的一部分进行比较。

一旦我们从 FileSystem 中得到了 WatchService 对象,我们将其注册到 test 路径以及我们感兴趣的项目的变量参数列表中,可以选择 ENTRY_CREATEENTRY_DELETEENTRY_MODIFY(其中创建和删除不属于修改)。

因为接下来对 watcher.take() 的调用会在发生某些事情之前停止所有操作,所以我们希望 deltxtfiles() 能够并行运行以便生成我们感兴趣的事件。为了实现这个目的,我通过调用 Executors.newSingleThreadScheduledExecutor() 产生一个 ScheduledExecutorService 对象,然后调用 schedule() 方法传递所需函数的方法引用,并且设置在运行之前应该等待的时间。

此时,watcher.take() 将等待并阻塞在这里。当目标事件发生时,会返回一个包含 WatchEventWatchkey 对象。展示的这三种方法是能对 WatchEvent 执行的全部操作。

查看输出的具体内容。即使我们正在删除以 .txt 结尾的文件,在 Hello.txt 被删除之前,WatchService 也不会被触发。你可能认为,如果说”监视这个目录”,自然会包含整个目录和下面子目录,但实际上:只会监视给定的目录,而不是下面的所有内容。如果需要监视整个树目录,必须在整个树的每个子目录上放置一个 Watchservice

  1. // files/TreeWatcher.java
  2. // {ExcludeFromGradle}
  3. import java.io.IOException;
  4. import java.nio.file.*;
  5. import static java.nio.file.StandardWatchEventKinds.*;
  6. import java.util.concurrent.*;
  7. public class TreeWatcher {
  8. static void watchDir(Path dir) {
  9. try {
  10. WatchService watcher =
  11. FileSystems.getDefault().newWatchService();
  12. dir.register(watcher, ENTRY_DELETE);
  13. Executors.newSingleThreadExecutor().submit(() -> {
  14. try {
  15. WatchKey key = watcher.take();
  16. for(WatchEvent evt : key.pollEvents()) {
  17. System.out.println(
  18. "evt.context(): " + evt.context() +
  19. "\nevt.count(): " + evt.count() +
  20. "\nevt.kind(): " + evt.kind());
  21. System.exit(0);
  22. }
  23. } catch(InterruptedException e) {
  24. return;
  25. }
  26. });
  27. } catch(IOException e) {
  28. throw new RuntimeException(e);
  29. }
  30. }
  31. public static void main(String[] args) throws Exception {
  32. Directories.refreshTestDir();
  33. Directories.populateTestDir();
  34. Files.walk(Paths.get("test"))
  35. .filter(Files::isDirectory)
  36. .forEach(TreeWatcher::watchDir);
  37. PathWatcher.delTxtFiles();
  38. }
  39. }
  40. /* Output:
  41. deleting test\bag\foo\bar\baz\File.txt
  42. deleting test\bar\baz\bag\foo\File.txt
  43. evt.context(): File.txt
  44. evt.count(): 1
  45. evt.kind(): ENTRY_DELETE
  46. */

watchDir() 方法中给 WatchSevice 提供参数 ENTRY_DELETE,并启动一个独立的线程来监视该Watchservice。这里我们没有使用 schedule() 进行启动,而是使用 submit() 启动线程。我们遍历整个目录树,并将 watchDir() 应用于每个子目录。现在,当我们运行 deltxtfiles() 时,其中一个 Watchservice 会检测到每一次文件删除。

文件查找

到目前为止,为了找到文件,我们一直使用相当粗糙的方法,在 path 上调用 toString(),然后使用 string 操作查看结果。事实证明,java.nio.file 有更好的解决方案:通过在 FileSystem 对象上调用 getPathMatcher() 获得一个 PathMatcher,然后传入您感兴趣的模式。模式有两个选项:globregexglob 比较简单,实际上功能非常强大,因此您可以使用 glob 解决许多问题。如果您的问题更复杂,可以使用 regex,这将在接下来的 Strings 一章中解释。

在这里,我们使用 glob 查找以 .tmp.txt 结尾的所有 Path

  1. // files/Find.java
  2. // {ExcludeFromGradle}
  3. import java.nio.file.*;
  4. public class Find {
  5. public static void main(String[] args) throws Exception {
  6. Path test = Paths.get("test");
  7. Directories.refreshTestDir();
  8. Directories.populateTestDir();
  9. // Creating a *directory*, not a file:
  10. Files.createDirectory(test.resolve("dir.tmp"));
  11. PathMatcher matcher = FileSystems.getDefault()
  12. .getPathMatcher("glob:**/*.{tmp,txt}");
  13. Files.walk(test)
  14. .filter(matcher::matches)
  15. .forEach(System.out::println);
  16. System.out.println("***************");
  17. PathMatcher matcher2 = FileSystems.getDefault()
  18. .getPathMatcher("glob:*.tmp");
  19. Files.walk(test)
  20. .map(Path::getFileName)
  21. .filter(matcher2::matches)
  22. .forEach(System.out::println);
  23. System.out.println("***************");
  24. Files.walk(test) // Only look for files
  25. .filter(Files::isRegularFile)
  26. .map(Path::getFileName)
  27. .filter(matcher2::matches)
  28. .forEach(System.out::println);
  29. }
  30. }
  31. /* Output:
  32. test\bag\foo\bar\baz\5208762845883213974.tmp
  33. test\bag\foo\bar\baz\File.txt
  34. test\bar\baz\bag\foo\7918367201207778677.tmp
  35. test\bar\baz\bag\foo\File.txt
  36. test\baz\bag\foo\bar\8016595521026696632.tmp
  37. test\baz\bag\foo\bar\File.txt
  38. test\dir.tmp
  39. test\foo\bar\baz\bag\5832319279813617280.tmp
  40. test\foo\bar\baz\bag\File.txt
  41. ***************
  42. 5208762845883213974.tmp
  43. 7918367201207778677.tmp
  44. 8016595521026696632.tmp
  45. dir.tmp
  46. 5832319279813617280.tmp
  47. ***************
  48. 5208762845883213974.tmp
  49. 7918367201207778677.tmp
  50. 8016595521026696632.tmp
  51. 5832319279813617280.tmp
  52. */

matcher 中,glob 表达式开头的 **/ 表示“当前目录及所有子目录”,这在当你不仅仅要匹配当前目录下特定结尾的 Path 时非常有用。单 * 表示“任何东西”,然后是一个点,然后大括号表示一系列的可能性—-我们正在寻找以 .tmp.txt 结尾的东西。您可以在 getPathMatcher() 文档中找到更多详细信息。

matcher2 只使用 *.tmp,通常不匹配任何内容,但是添加 map() 操作会将完整路径减少到末尾的名称。

注意,在这两种情况下,输出中都会出现 dir.tmp,即使它是一个目录而不是一个文件。要只查找文件,必须像在最后 files.walk() 中那样对其进行筛选。

文件读写

此时,我们可以对路径和目录做任何事情。 现在让我们看一下操纵文件本身的内容。

如果一个文件很“小”,也就是说“它运行得足够快且占用内存小”,那么 java.nio.file.Files 类中的实用程序将帮助你轻松读写文本和二进制文件。

Files.readAllLines() 一次读取整个文件(因此,“小”文件很有必要),产生一个List<String>。 对于示例文件,我们将重用streams/Cheese.dat

  1. // files/ListOfLines.java
  2. import java.util.*;
  3. import java.nio.file.*;
  4. public class ListOfLines {
  5. public static void main(String[] args) throws Exception {
  6. Files.readAllLines(
  7. Paths.get("../streams/Cheese.dat"))
  8. .stream()
  9. .filter(line -> !line.startsWith("//"))
  10. .map(line ->
  11. line.substring(0, line.length()/2))
  12. .forEach(System.out::println);
  13. }
  14. }
  15. /* Output:
  16. Not much of a cheese
  17. Finest in the
  18. And what leads you
  19. Well, it's
  20. It's certainly uncon
  21. */

跳过注释行,其余的内容每行只打印一半。 这实现起来很简单:你只需将 Path 传递给 readAllLines() (以前的 java 实现这个功能很复杂)。readAllLines() 有一个重载版本,包含一个 Charset 参数来存储文件的 Unicode 编码。

Files.write() 被重载以写入 byte 数组或任何 Iterable 对象(它也有 Charset 选项):

  1. // files/Writing.java
  2. import java.util.*;
  3. import java.nio.file.*;
  4. public class Writing {
  5. static Random rand = new Random(47);
  6. static final int SIZE = 1000;
  7. public static void main(String[] args) throws Exception {
  8. // Write bytes to a file:
  9. byte[] bytes = new byte[SIZE];
  10. rand.nextBytes(bytes);
  11. Files.write(Paths.get("bytes.dat"), bytes);
  12. System.out.println("bytes.dat: " + Files.size(Paths.get("bytes.dat")));
  13. // Write an iterable to a file:
  14. List<String> lines = Files.readAllLines(
  15. Paths.get("../streams/Cheese.dat"));
  16. Files.write(Paths.get("Cheese.txt"), lines);
  17. System.out.println("Cheese.txt: " + Files.size(Paths.get("Cheese.txt")));
  18. }
  19. }
  20. /* Output:
  21. bytes.dat: 1000
  22. Cheese.txt: 199
  23. */

我们使用 Random 来创建一个随机的 byte 数组; 你可以看到生成的文件大小是 1000。

一个 List 被写入文件,任何 Iterable 对象也可以这么做。

如果文件大小有问题怎么办? 比如说:

  1. 文件太大,如果你一次性读完整个文件,你可能会耗尽内存。

  2. 您只需要在文件的中途工作以获得所需的结果,因此读取整个文件会浪费时间。

Files.lines() 方便地将文件转换为行的 Stream

  1. // files/ReadLineStream.java
  2. import java.nio.file.*;
  3. public class ReadLineStream {
  4. public static void main(String[] args) throws Exception {
  5. Files.lines(Paths.get("PathInfo.java"))
  6. .skip(13)
  7. .findFirst()
  8. .ifPresent(System.out::println);
  9. }
  10. }
  11. /* Output:
  12. show("RegularFile", Files.isRegularFile(p));
  13. */

这对本章中第一个示例代码做了流式处理,跳过 13 行,然后选择下一行并将其打印出来。

Files.lines() 对于把文件处理行的传入流时非常有用,但是如果你想在 Stream 中读取,处理或写入怎么办?这就需要稍微复杂的代码:

  1. // files/StreamInAndOut.java
  2. import java.io.*;
  3. import java.nio.file.*;
  4. import java.util.stream.*;
  5. public class StreamInAndOut {
  6. public static void main(String[] args) {
  7. try(
  8. Stream<String> input =
  9. Files.lines(Paths.get("StreamInAndOut.java"));
  10. PrintWriter output =
  11. new PrintWriter("StreamInAndOut.txt")
  12. ) {
  13. input.map(String::toUpperCase)
  14. .forEachOrdered(output::println);
  15. } catch(Exception e) {
  16. throw new RuntimeException(e);
  17. }
  18. }
  19. }

因为我们在同一个块中执行所有操作,所以这两个文件都可以在相同的 try-with-resources 语句中打开。PrintWriter 是一个旧式的 java.io 类,允许你“打印”到一个文件,所以它是这个应用的理想选择。如果你看一下 StreamInAndOut.txt,你会发现它里面的内容确实是大写的。

本章小结

虽然本章对文件和目录操作做了相当全面的介绍,但是仍然有没被介绍的类库中的功能——一定要研究 java.nio.file 的 Javadocs,尤其是 java.nio.file.Files 这个类。

Java 7 和 8 对于处理文件和目录的类库做了大量改进。如果您刚刚开始使用 Java,那么您很幸运。在过去,它令人非常不愉快,我确信 Java 设计者以前对于文件操作不够重视才没做简化。对于初学者来说这是一件很棒的事,对于教学者来说也一样。我不明白为什么花了这么长时间来解决这个明显的问题,但不管怎么说它被解决了,我很高兴。使用文件现在很简单,甚至很有趣,这是你以前永远想不到的。