3.4 NIO.2

Java NIO在Java I/O库的基础上增加了通道的概念,提供了I/O操作的性能,使用起来也更加简单。Java 7中的I/O库得到了进一步增强,称为NIO.2。NIO.2中包含的主要内容包括文件系统访问和异步I/O通道。

3.4.1 文件系统访问

3.3.1 节介绍了文件通道相关的内容。相对于传统的基于java.io.File类的文件操作来说,文件通道在某些方面更加简单和高效,但还是有一些与文件相关的操作需要依靠File类来完成,比如列出某个目录下的所有文件的操作。File类中的操作存在一些不方便开发人员使用的地方,对此,Java 7加强了文件操作相关的功能,即新的java.nio.file包,其所提供的新功能包括文件路径的抽象、文件目录列表流、文件目录树遍历、文件属性和文件变化监视服务等。

1.文件路径的抽象

在使用java.io.File类来操作文件时,需要以字符串的方式指定文件的路径。虽然文件路径本身最终的表现形式是字符串,但是直接利用字符串来进行与路径相关的操作时,丢失了路径本身的语义。比如,一个路径中可能包含多个子部分,每个部分表示一个目录或是文件,如果希望把两个路径连接起来得到一个新的路径,传统的做法是进行字符串相加。这种做法不是类型安全的,要正确无误地实现也不是那么容易。NIO.2中引入的java.nio.file.Path接口作为文件系统中路径的一个抽象,很好地解决了这个问题。Path接口除了带来了类型安全的好处之外,还提供了操作路径的很多实用方法,使开发人员不再需要编写很多重复的代码。类型安全的重要性是很明显的。在有了Path接口之后,就不会在一个需要使用文件路径的地方将一个随意的字符串作为参数传递进去。在使用String类的对象来表示文件路径时,这种错误是很可能发生的。

Path接口相当于将一个字符串表示的文件路径重新细分,使之赋有语义。以一个典型的文件路径“C:\foo\bar\myfile.txt”为例,如果得到了它对应的Path接口的实现,那么Path接口实现对象中的“根”指的是“C:\”,可以通过getRoot方法来获取,路径中通过路径分隔符隔开的每一个部分都成为其中的名称元素。该路径有3个名称元素,分别是表示目录名或文件名的“foo”、“bar”和“myfile.txt”,可以通过getNameCount获取到名称元素的总数,通过getName来获取单个名称元素;在路径中距离根最远的名称元素,称为该路径的文件名,可以通过getFileName方法来获取,这里的值是“myfile.txt”;通过getParent可以获取到当前路径的父路径,这里的值是“C:\foo\bar”。

有了Path接口之后,对文件路径进行的很多操作变得很简单,不再需要依靠复杂的字符串操作。代码清单3-17给出了通过Path接口操作文件路径的示例,每个方法调用的后面用注释的形式给出了运行结果。Path接口中resolve方法的作用相当于把当前路径当成父目录,而把参数中的路径当成子目录或是其中的文件,进行解析之后得到一个新路径;resolveSibling方法的作用与resolve方法类似,只不过把当前路径的父目录当成解析时的父目录;relativize方法的作用与resolve方法正好相反,用来计算当前路径相对于参数中给出的路径的相对路径;subpath方法用来获取当前路径的子路径,参数中的序号表示的是路径中名称元素的序号;startsWith和endsWith方法用来判断当前路径是否以参数中的路径开始或结尾。在一般的路径中,“.”和“..”分别用来表示当前目录和上一级目录。通过normalize方法可以去掉路径中的“.”和“..”。所有这些方法的返回值都是Path接口的实现对象,因此这些方法可以很容易地级联起来。

代码清单3-17 Path接口的使用示例


public void usePath(){

Path path1=Paths.get("folder1","sub1");

Path path2=Paths.get("folder2","sub2");

path1.resolve(path2);//folder1\sub1\folder2\sub2

path1.resolveSibling(path2);//folder1\folder2\sub2

path1.relativize(path2);//....\folder2\sub2

path1.subpath(0,1);//folder1

path1.startsWith(path2);//false

path1.endsWith(path2);//false

Paths.get("folder1/./../folder2/my.text").normalize();//folder2\my.text

}


2.文件目录列表流

当需要列出一个目录下的子目录和文件时,传统的做法是使用File类中的list或listFiles方法。不过这两个方法在目录中包含的文件数量很多的时候,性能比较差。NIO.2中引入了一个新的接口java.nio.file.DirectoryStream来支持这种遍历操作。DirectoryStream接口继承了java.lang.Iterable接口,使DirectoryStream接口的实现对象可以直接在增强的for循环中使用。DirectoryStream接口的优势在于它渐进式地遍历文件,每次只读取一定数量的内容,从而可以降低遍历时的开销。在实际的使用中,DirectoryStream接口的实现对象是通过java.nio.file.Files类的工厂方法来创建的。在创建时,可以指定遍历时的过滤条件,即满足何种条件的目录和文件才会被包括进来。指定遍历条件时既可以使用DirectoryStream.Filter接口实现类的对象,也可以使用字符串来表示简单的模式。代码清单3-18中给出了遍历当前目录下所有的“.java”文件的示例。

代码清单3-18 目录列表流的使用示例


public void listFiles()throws IOException{

Path path=Paths.get("");

try(DirectoryStream<Path>stream=Files.newDirectoryStream(path,"*.java")){

for(Path entry:stream){

//使用entry

}

}

}


如果希望程序自己来遍历DirectoryStream接口实现对象中的条目,可以通过iterator方法获取到DirectoryStream接口实现对象的迭代器对象。不过iterator方法只能调用一次,得到唯一的一个迭代器对象。如果在遍历过程中,目录中的文件发生了变化,这种变化可能会被迭代器捕获到,也可能不会。程序不应该依赖迭代器来发现这些变化,更好的做法是使用目录监视服务。

3.文件目录树遍历

DirectoryStream接口只能遍历当前目录下的直接子目录或文件,并不会递归地遍历子目录下的子目录。如果希望对整个目录树进行遍历,需要使用java.nio.file.FileVisitor

接口。FileVisitor接口是典型的访问者模式的实现。在这个接口中定义了4个方法,分别表示对目录树的不同访问动作。首先是visitFile方法,它表示正在访问某个文件;其次是visitFileFailed方法,它表示访问某个文件时出现了错误;接着是preVisit-Directory方法,它表示在访问一个目录中包含的子目录和文件之前被调用;最后是postVisitDirectory方法,它表示在访问一个目录的全部子目录中的内容之后被调用。这4个方法都返回java.nio.file.FileVisitResult枚举类型,用来声明整个遍历过程的下一步动作。在这些枚举值中:CONTINUE表示继续进行正常的遍历过程;SKIP_SIBLINGS表示跳过当前目录或文件的兄弟节点;SKIP_SUBTREE表示不再遍历当前目录中的内容;TERMINATE表示立即结束整个遍历过程。通过实现这4个方法并根据情况返回相应的遍历动作,程序可以很容易地控制整个遍历过程,并在遍历中对整个目录树进行修改。

下面通过一个具体的示例来进行说明。在开发过程中我们有时候会使用Subversion作为源代码配置管理的工具。Subversion会在其管理的目录下面添加“.svn”子目录来保存其所需的元数据。如果直接把整个目录复制给其他人,会发现“.svn”目录也包含在其中。这个目录是没有必要存在的。代码清单3-19给出了一个遍历某个目录并清除其中包含的“.svn”目录的FileVisitor接口的实现。SvnInfoCleanVisitor类并没有直接实现FileVisitor接口,而是继承自java.nio.file.SimpleFileVisitor类。SimpleFileVisitor类是一个简单的FileVisitor接口的适配器。通过继承SimpleFileVisitor类的方式可以不必实现FileVisitor接口中的全部方法。在进行遍历的过程中,如果遇到一个名称为“.svn”的目录,则说明需要将此目录下的所有子目录和文件删除。由于“.svn”目录中的文件是只读的,因此在删除文件时,需要先取消对只读属性的设置,再进行删除。在进行删除操作时,需要先删除目录中包含的文件,再删除该目录。

代码清单3-19 删除Subversion元数据的目录遍历方式


public class SvnInfoCleanVisitor extends SimpleFileVisitor<Path>{

private boolean cleanMark=false;

private boolean isSvnFolder(Path dir){

return".svn".equals(dir.getFileName().toString());

}

public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)throws IOException{

if(isSvnFolder(dir)){

cleanMark=true;

}

return FileVisitResult.CONTINUE;

}

public FileVisitResult postVisitDirectory(Path dir, IOException e)throws IOException{

if(e==null&&cleanMark){

Files.delete(dir);

if(isSvnFolder(dir)){

cleanMark=false;

}

}

return FileVisitResult.CONTINUE;

}

public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)throws IOException{

if(cleanMark){

Files.setAttribute(file,"dos:readonly",false);

Files.delete(file);

}

return FileVisitResult.CONTINUE;

}

}


4.文件属性

文件属性是文件除了本身的数据之外的元数据。常见的属性包括是否只读、是否为隐藏文件、上次访问时间和所有者信息等。在Java 7之前,Java标准库只有少量与文件属性相关的方法,主要在File类中。这些方法的功能比较弱,而且也不够系统。在Java 7中,NIO.2提供了专门的java.nio.file.attribute包来对文件属性进行处理。由于不同操作系统上的文件系统对文件属性的支持是不同的,NIO.2对文件属性进行了抽象,采用了文件属性视图的概念。每个属性视图中包含了可以从这个视图中获取和设置的各种属性。不同的视图所包含的属性是不一样的。每个属性视图都有自己的名称。

接口java.nio.file.attribute.AttributeView是所有属性视图的父接口。AttributeView的子接口java.nio.file.attribute.FileAttributeView表示的是文件的属性视图。FileAttributeView接口表示的属性视图分为两类,一类是由java.nio.file.attribute.BasicFileAttributeView接口表示的包含基本文件属性的视图,另外一类是由java.nio.file.attribute.FileOwnerAttributeView接口表示的包含文件所有者信息的属性视图。调用BasicFileAttributeView接口的readAttributes方法可以获取到表示文件基本属性的java.nio.file.attribute.BasicFileAttributes接口的实现对象。BasicFileAttributes接口中包含了类型安全的方法,这些方法用来获取不同文件系统中的通用属性,包括创建时间(creationTime)、上次访问时间(lastAccessTime)、上次修改时间(lastModifiedTime)、是否是目录(isDirectory)、是否为普通文件(isRegularFile)、是否为符号链接(isSymbolicLink)和文件大小(size)等。FileOwnerAttributeView接口的getOwner和setOwner方法可以用来获取和设置文件的所有者信息。

Windows操作系统中的文件系统一般使用遗留的“DOS”文件属性视图,由java.nio.file.attribute.DosFileAttributeView接口来表示。DosFileAttributeView接口中包含的属性有是否为只读文件(readonly)、是否为隐藏文件(hidden)、是否为系统文件(system)和是否为归档文件(archive)等。DosFileAttributeView接口中包含了设置这些属性的方法。如果需要获取属性的值,先通过readAttributes方法获取到java.nio.file.attribute.DosFileAttributes接口的实现对象,再调用该实现对象中的对应方法来获取属性值。与DosFileAttributeView接口相对应的是java.nio.file.attribute.PosixFileAttributeView接口,表示UNIX和Linux系统使用的POSIX文件属性视图。PosixFileAttributeView接口中包含的属性有所有者信息(group)和权限信息(permissions)。PosixFileAttributeView接口的使用方式类似于DosFileAttributeView接口,使用PosixFileAttributeView接口本身提供的方法来进行属性设置;通过readAttributes方法获取java.nio.file.attribute.PosixFileAttributes接口的实现对象来读取属性的值。

上面介绍的属性视图相关的表示方式都是接口。实际的获取和设置文件属性的操作是通过Files类中的静态方法来完成的。Files类提供的与文件属性相关的方法比较多,分成获取和设置属性两类。获取属性的方式有两种:一种是获取FileAttributeView接口或BasicFileAttributes接口的实现对象后,再调用相应的方法来获取属性的值;另外一种是直接指定属性的名称来获取相应的值。Files类中的getFileAttributeView方法用来获取FileAttributeView接口的实现对象,在调用时需要指定所要获取的属性视图的类名。代码清单3-20通过getFileAttributeView方法获取DosFileAttributeView接口中包含的文件的“是否只读”属性的值。

代码清单3-20 文件属性视图的使用示例


public void useFileAttributeView()throws IOException{

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

DosFile Attribute View view=Files.getFile Attribute View(path, DosFileAttributeView.class);

if(view!=null){

DosFileAttributes attrs=view.readAttributes();

System.out.println(attrs.isReadOnly());

}

}


在代码清单3-20中,获取到DosFileAttributeView接口的实现对象之后,还需要调用其readAttributes方法来获取具体的包含属性的对象。从简化使用的角度出发,Files类中提供了readAttributes方法来直接获取特定类型的BasicFileAttributes接口的实现对象。

Files类中的readAttributes和getAttribute方法可以根据属性名称来获取对应的值。不同之处在于readAttributes方法可以批量获取一组属性的值,而getAttribute方法只能获取一个属性的值。使用这两个方法时需要指定文件的路径及属性的名称。属性的名称是带名称空间的,其前缀是属性所在的属性视图的名称,比如“DOS”文件属性视图中的“是否为隐藏文件”属性的完整名称是“dos:hidden”。在不带前缀的情况下,默认属性来自基本属性视图。在调用readAttributes方法时,多个属性名称之间用逗号分隔即可。代码清单3-21给出了一个通过检查文件的上次修改时间来判断文件是否需要更新的示例,文件的上次修改时间对应的属性名称是“lastModifiedTime”。由于“lastModifiedTime”属性在基本属性视图中,因此使用时不需要添加视图名称作为前缀。文件属性中的创建时间、上次修改时间和上次访问时间都是由java.nio.file.attribute.FileTime类的对象来表示的。

代码清单3-21 获取文件的上次修改时间的示例


public boolean checkUpdatesRequired(Path path, int intervalInMillis)throws

IOException{

FileTime lastModifiedTime=(FileTime)Files.getAttribute(path,"lastModifiedTime");

long now=System.currentTimeMillis();

return now-lastModifiedTime.toMillis()>intervalInMillis;

}


在设置文件属性值时,也有两种对应的方式:一种是使用Files类的getFile-AttributeView方法获取到FileAttributeView接口的实现对象之后,通过该对象提供的方法来进行设置;另外一种是调用Files的setAttribute方法,设置时使用的是属性名称。Files类中也提供了一些快捷的方法来获取和设置常见文件属性的值,比如getOwner/setOwner和getLastModifiedTime/setLastModifiedTime等实用方法。

5.监视目录变化

在实际开发中可能会需要监视某个目录下的文件所发生的变化,比如支持热部署的Web容器需要监视某个特定目录下是否出现新的待部署的Web应用的归档文件。另外一个场景是程序的输入来自某个特定目录下面的文本文件,要求每出现一个文件就立即进行处理。在Java 7之前,这种目录监视功能需要开发人员自己来实现。一般的做法是:在一个独立的线程中使用File类的listFiles方法来定时检查目录中的内容,并与之前的内容进行比较,从而判断是否有新的文件出现,文件内容是否被修改或被删除。NIO.2中提供了新的目录监视服务,使用该服务可以在指定目录中的子目录或文件被创建、更新或删除时得到事件通知。基于这些通知,程序可以进行相应的处理。

目录监视服务的使用方式类似于3.3.2节介绍的非阻塞式套接字通道与选择器的使用方式,被监视的对象要实现java.nio.file.Watchable接口,并通过register方法注册到表示监视服务的java.nio.file.WatchService接口的实现对象上,注册时需要指定被监视对象感兴趣的事件类型。注册成功之后,调用者可以得到一个表示这次注册行为的java.nio.file.WatchKey接口的实现对象,其作用类似于SelectionKey类。通过WatchKey接口可以获取在对应的被监视对象上所产生的事件。每个事件用java.nio.file.WatchEvent接口来表示。与Selector类中的select方法一样,WatchService接口也提供了类似的方法来获取当前所有被监视的对象上的可用事件。查询的方式也分成阻塞式和非阻塞式两种:阻塞式方式使用的是take方法,而非阻塞式方式使用的是poll方法。查询结果的返回值是WatchKey接口的实现对象。调用WatchKey接口的pollEvents方法可以得到对应被监视对象上所发生的所有事件。

代码清单3-22中的代码会监视当前的工作目录,当有新的文件被创建时,输出该文件的大小。WatchService接口的实现对象是由工厂方法创建的,需要从表示文件系统的java.nio.file.FileSystem类的对象中得到。目前,唯一可以被监视的对象只有Path接口的实现对象。可以被监视的事件包括创建或重命名(ENTRY_CREATE)、更新(ENTRY_MODIFY)和删除(ENTRY_DELETE)。这些事件定义在java.nio.file.StandardWatchEventKinds类中。当有事件发生时,通过对应的WatchKey接口的实现对象的pollEvents方法获取所有的事件。WatchEvent接口的context方法的返回值表示的是事件的上下文信息。在与目录内容变化相关的事件中,上下文信息是一个Path接口的实现对象,表示的是产生事件的文件路径相对于被监视路径的相对路径,因此需要使用Path接口的resolve方法来得到完整的路径。在处理完一个WatchKey接口实现对象中的全部事件之后,需要通过reset方法来进行重置。只有在重置之后,新产生的同类事件才有可能从WatchService接口实现对象中再次获取。

代码清单3-22 目录监视服务的使用示例


public void calculate()throws IOException, InterruptedException{

WatchService service=FileSystems.getDefault().newWatchService();

Path path=Paths.get("").toAbsolutePath();

path.register(service, StandardWatchEventKinds.ENTRY_CREATE);

while(true){

WatchKey key=service.take();

for(WatchEvent<?>event:key.pollEvents()){

Path createdPath=(Path)event.context();

createdPath=path.resolve(createdPath);

long size=Files.size(createdPath);

System.out.println(createdPath+"==>"+size);

}

key.reset();

}

}


如果希望取消对一个目录的监视,只需要调用对应WatchKey接口实现对象的cancel方法即可。

6.文件操作的实用方法

在程序中进行文件操作时,经常会使用一些通用操作。Files类中提供了一系列的静态方法,可以满足很多常见的需求。在前面给出的示例代码中,大量用到了Files类。

Files类中提供了创建目录和文件的功能。Files类中提供的方法既可以创建目录和一般文件,也可以创建符号连接,还可以创建临时目录和临时文件。在创建时可以指定新目录和文件的属性。Files类还提供了复制文件的功能,既支持从一个InputStream类的对象中读入数据到一个文件,也支持从一个文件中读取数据并写入到一个OutputStream类的对象中,还支持两个文件之间的数据传递。这个功能类似于3.3.1节介绍的文件通道的数据传输功能。在文件读写方面,Files类支持一次性读入文件的所有字节或所有行,也支持把一个字节数组和一组字符串写入到文件中。除了这些之外,Files类对文件的删除和移动也提供了支持。所有这些操作在指定目录或文件时都是使用Path接口来表示的。代码清单3-23中给出了Files类中部分实用方法的使用示例。

代码清单3-23 文件操作的实用方法的使用示例


public void manipulateFiles()throws IOException{

Path newFile=Files.createFile(Paths.get("new.txt").toAbsolutePath());

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

content.add("Hello");

content.add("World");

Files.write(newFile, content, Charset.forName("UTF-8"));

Files.size(newFile);

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

ByteArrayOutputStream output=new ByteArrayOutputStream();

Files.copy(newFile, output);

Files.delete(newFile);

}


在Files中,除了有直接操作文件的方法之外,还有把文件转换成各种不同形式的方法,比如,newInputStream和newOutputStream方法可以分别得到一个文件对应的InputStream类的对象和OutputStream类的对象;newBufferedReader和newBufferedWriter方法可以得到文件对应的BufferedReader类的对象和BufferedWriter类的对象;newByteChannel可以得到一个实现SeekableByteChannel接口的通道对象。