文件

在Java 7中,新的文件操作放在java.nio.file包下,n可以理解为non-blocking(非阻塞)。Java 8新增的streams与文件结合使得文件操作编程变得更加优雅。文件操作的两个基本组件:

  1. 文件或目录的路径

  2. 文件本身。

文件和目录路径

Path 对象表示一个文件或者目录的路径,是一个跨操作系统(OS)和文件系统的抽象,目的是在构造路径时不必关注底层操作系统,代码可以在不进行修改的情况下运行在不同的操作系统上。

java.nio.file.Paths 类包含一个重载方法 static get(),该方法接受一系列 String 字符串或一个统一资源标识符(URI)作为参数,并且进行转换返回一个 Path 对象:

import java.io.*;
import java.net.URI;
import java.nio.file.*;

public class PathInfo {
    static void show(String id, Object p) {
        System.out.println(id + ": " + p);
    }
    static void info(Path p) {
        show("toString", p);
        show("Exists", Files.exists(p));
        show("RegularFile", Files.isRegularFile(p));//是否是文件
        show("Directory", Files.isDirectory(p));//是否是目录
        show("Absolute", p.isAbsolute());//是否绝对路径
        show("FileName", p.getFileName());
        show("Parent", p.getParent());
        show("Root", p.getRoot());
        System.out.println("******************");
    }
    public static void main(String[] args) {
        System.out.println(System.getProperty("os.name"));
        info(Paths.get("D:", "path", "NoFile.txt"));
        Path p = Paths.get("pom.xml");
        info(p);
        Path ap = p.toAbsolutePath();//得到绝对路径
        info(ap);
        info(ap.getParent());
        try {
            info(p.toRealPath());
        }catch (IOException e){
            System.out.println(e);
        }
        URI u = p.toUri(); // 在Path对象p前加上当前工作的目录前缀
        System.out.println("URI: " + u);
        Path puri = Paths.get(u);
        System.out.println(Files.exists(puri));
        File f = ap.toFile();// 非“文件”对象,实际表示应该为“路径”
    }
}
/* output
Windows 10
toString: D:\path\NoFile.txt
Exists: false
RegularFile: false
Directory: false
Absolute: true
FileName: NoFile.txt
Parent: D:\path
Root: D:\
******************
toString: pom.xml
Exists: true
RegularFile: true
Directory: false
Absolute: false
FileName: pom.xml
Parent: null
Root: null
******************
toString: D:\project\java\files\file\pom.xml
Exists: true
RegularFile: true
Directory: false
Absolute: true
FileName: pom.xml
Parent: D:\project\java\files\file
Root: D:\
******************
toString: D:\project\java\files\file
Exists: true
RegularFile: false
Directory: true
Absolute: true
FileName: file
Parent: D:\project\java\files
Root: D:\
******************
toString: D:\project\java\files\file\pom.xml
Exists: true
RegularFile: true
Directory: false
Absolute: true
FileName: pom.xml
Parent: D:\project\java\files\file
Root: D:\
******************
URI: file:///D:/project/java/files/file/pom.xml
true */

可以看到,toString() 生成的是路径的完整表示,而 getFileName() 生成的是文件的名字。使用Files工具类,可以测试文件是否存在、是否为“普通”文件、是否为目录等。"Nofile.txt"这个示例展示我们描述的文件可能并不在指定的位置;这样可以允许你创建一个新的路径。"pom.xml"存在于当前目录中,最初它只是没有路径的文件名,但它仍然被检测为“存在”。一旦我们将其转换为绝对路径,我们将会得到一个从"D:"盘(因为这里是在Windows机器下进行测试)开始的完整路径。

选取路径部分片段

可以通过 getName() 来索引 Path 的各个部分,直到达到上限 getNameCount()。Path 也实现了 Iterable 接口,因此我们也可以通过增强的 for-each 进行遍历。

注意endsWith() 比较的是路径最后的整个片段,如果比较“.xml”将会false。且遍历Path对象并不包含根路径,只有使用 startsWith() 检测根路径时才会返回 true。

路径分析

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

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

添加或删除路径片段

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

relativize() 方法接受另一个路径作为参数,然后返回一个新的 Path 对象,该对象代表当前路径与给定路径的相对路径。(即在此路径和给定路径之间构建一个相对路径)resolve() 方法接受一个 Path 对象作为参数,然后将其添加到调用该方法的路径之后,形成一个新的路径。如果传入的参数是绝对路径,它将替换调用方法的路径;如果传入的参数是相对路径,它将追加在调用方法的路径之后。

normalize() 方法用于规范化路径,去除路径中的冗余部分并解析 '.' 和 '..' 符号。'..'表示上一目录,'.'表示当前目录。resolveSibling() 方法接受一个 Path 对象作为参数,它会返回一个新的 Path 对象,该对象的父级目录与当前路径的父级目录相同,但是最后一个元素是传入的参数作为文件或目录的名称。该方法对于在处理文件路径时,要在当前路径的父级目录下创建新的文件或目录时非常有用。

目录

Files工具类由于某些原因,其中没有包含用于删除目录树的工具,这里需要自己创建一个:

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

  • preVisitDirectory(): 先在当前目录运行,然后进入这个目录下的文件和目录。

  • visitFile(): 在这个目录下的每个文件上运行。

  • visitFileFailed(): 当文件无法访问时调用。

  • postVisitDirectory(): 先进入当前目录下的文件和目录(包括所有子目录),最后在当前目录上运行。

为了简化,java.nio.file.SimpleFileVisitor 为所有这些方法提供了默认的定义。这样,在匿名内部类中,我们只需要重写非标准行为的方法:visitFile()postVisitDirectory() 分别实现删除文件和删除目录。这两个方法的返回标志都表示应该继续遍历(这样就可以继续访问,直到找到所需要的)。

现在可以有条件地删除已存在的目录。以下的例子中,makeVarient()接受一个基准目录test,然后通过旋转parts列表生成不同的子目录路径。使用String.join()将旋转后的parts通过路径分隔符sep连接到一起,然后将结果作为Path返回。

首先,refreshTesstDir() 检查test是否已经存在,若存在,则使用定义的新工具类 rmdir() 删除其整个目录。再次检查是否 exists 是多余的,但是如果你对于已经存在的目录调用 createDirectory() 将会抛出异常。

createFile() 使用参数 Path 创建一个空文件; resolve() 将文件名添加到 test Path 的末尾。

createDirectory() 只能创建单层目录,这里创建多层目录会抛出异常。

populateTestDir() 中,对于每一个varient使用 createDirectaries() 创建完整的目录路径,然后将"../strings/new.txt"的文件副本放到最后一层目录中,并更换了文件名。之后又使用 createTempFile() 生成了一个临时文件,通过将前缀和后缀都设为null来让方法创建一个默认文件名加后缀的临时文件(即一串数字加'.tmp')。

在调用 populateTestDir() 之后,又在test下面创建了一个临时目录,因为是目录所以该方法只有一个前缀选项,而 createTempFile() 生成的临时文件还可以指定后缀。

为了显示结果,使用 newDirectaryStream(),流中只有test目录下的内容,没有更下级目录的内容。要获得包含整个目录树内容的流,应使用 Files.walk()

文件系统

可以使用静态的FileSystems工具来获得“默认”的文件系统,也可以在一个Path对象上调用getFileSystem() 来获得创建这个路径对象的文件系统。还可以通过给定的URI获得一个文件系统,可以构建新的文件系统(对于支持它的操作系统)。

FileSystem也能生成WatchService和PathMatcher对象。

监听Path

通过WatchService可以设置一个进程,对某个目录中的变化做出反应。在下例中,delTxtFiles() 作为一个独立的任务运行,它会遍历整个目录树,删除所有名字以.txt结尾的文件,WatchService会对文件的删除做出反应。

delTxtFiles()中的try块捕获相同类型的异常,似乎有外部的try块就足够了,然而由于某些原因(可能是bug),Java要求两者都存在。在filter()中,必须显示地调用f.toString(),否则endsWith()会比较整个Path对象,而不是其用字符串表示的名字部分。

一旦从FileSystem得到一个WatchService,就将它和由我们感兴趣的事件组成的可变参数列表一起注册给test这个Path。感兴趣的事件可以从ENTRY_CREATE、ENTRY_DELETE、ENTRY_MODIFY中选择(创建和删除不属于修改)。

因为即将开始的对watcher.take()的调用会停掉一切工作,直到某个事件发生才恢复,所以我们希望delTxtFiles()以并行方式开始运行,以便它能产生我们期望发生的事件。于是,通过调用Executors.newSingleThreadScheduledExecutor()获得一个ScheduledExecutorService,然后调用schedule(),传递所需函数的方法引用并设置运行之前应该等待的时间。

然后,我们调用watcher.take(),主线程会在这里等待。当目标事件发生时,会返回一个包含 WatchEvent 的 WatchKey。这里演示的三个方法是能对WatchEvent做的所有事情。

通过查看输出,发现尽管我们正在删除名字以.txt结尾的文件,但在Hello.txt删除之前(即子目录下的.txt文件删除时),WatchService不会触发。这里实际上它只监听这个目录,而不是它下面所有的子目录。如果想监听整个目录树,则必须在整个树的每个子目录上设置一个WatchService。

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

查找文件

到目前为止,我们查找文件还是在Path上调用toString(),然后使用String的各种操作来查看结果。其实java.nio.file有一个更好的解决方案:PathMatcher。可以通过在FileSystem对象上调用getPathMatcher()来获得一个PathMatcher,并传入我们感兴趣的模式(有两个选项:globregex)。glob更简单,但实际上非常强大,可以解决很多问题。如果问题更为复杂,可以使用regex。

这里使用glob来查找所有文件名以.tmp和.txt结尾的Path。

matcher中,glob表达式开头的**/表示"所有子目录",当你想匹配的不仅仅是以基准目录为结尾的Path时很有用,因为它匹配的是完整路径,直到找到想要的结果。单个*表示"任何东西",然后是一个英文句号,加上括号中的一系列可能性(即正在查找任何以.tmp和.txt结尾的东西),更多参考getPathMatcher()的文档。

matcher2只是使用了*.tmp,这里不会匹配到任何东西,但添加map()操作后会将完整路径减少到只剩下最后的文件名。

如果只是用map()dir.tmp这个目录也出现在输出中,如果只想要寻找文件,必须还要像最后Files.walk()那样进行过滤。

读写文件

如果一个文件很“小”,也就是说“它运行得足够快且占用内存小”,java.nio.file.Files类包含了方便读写文本文件和二进制文件的工具函数。

Files.readAllLines()可以一次性读入整个文件(因此“小”文件很必要),生成一个List<String>

跳过了以p开头的行,其余内容只打印一半。很简单的操作,只需要把一个Path对象交给readAllLine()readAllLine()有一个重载版本,还包含一个Charset参数,用来确定文件的Unicode编码。

Files.write()也被重载了,可以将byte数组或任何实现了Iterable接口的类的对象(还包括一个Charset选项)写入文件。

这里使用Random创建了1000个随机的byte,可以看到生成的文件大小是1000。然后又将一个List对象写到了文件中,但是任何实现了Iterable接口的类的对象都是可以的。

Files.lines()可以很方便地将一个文件变为一个由行组成的Stream。

这里将文件中内容做了流式处理,跳过13行,取得下一行并打印。

如果把文件当作一个由行组成的输入流来处理,那么Files.lines()非常有用,但是如果我们想在一个流中完成读取、处理和写入,就需要复杂些的代码了。

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

小结

这里还有一些没有涉及到的特性,更多参考java.nio.file文档,特别是java.nio.file.Files