在丑陋的 Java I/O 编程方式诞生多年以后,Java终于简化了文件读写的基本操作。
很多学得比较快的同学可能学习过Java的文件读写,就是诸如 InputStream, OutputStream 一样的东西,如上面所说的一样,他们既丑陋又难用,学习路线非常曲折。不过好在 Java 设计者终于意识到了 Java 使用者多年来的痛苦,在 Java7 中对此引入了巨大的改进,这些新特性被放在 java.nio.file 包下面。除此之外,Java8 新增的 streams 与文件结合使得文件操作变得更加优雅易用,可以说从此以后我们几乎再也不需要见到 InputStream 这种难用的东西了。
首先让我们将看一下文件操作的两个基本概念:
- 文件的路径
- 文件本身
可能有同学要问我这里为什么只提到了文件而没有提到目录,我这里说的文件是指广义的文件(File),包括我们认为的文件(RegularFile)和目录(Directory),为了避免歧义,之后尽量用英文说明狭义的文件(RegularFile)。
一、文件的路径
路径,也就是 Path ,例如"C://Windows/Fonts"、"/Users/tommy0607/Downloads/InputFix.jar"等等都是路径。
java.nio.file.Paths 类包含一个重载方法 static get(String first, String... more)
,该方法通过输入一系列路径片段可以返回一个路径。
例如想要获得 /Users/tommy0607/Downloads 的路径,可以用下面三种方式:
- 直接用全路径:
Paths.get("/Users/tommy0607/Downloads")
- 用父目录的路径名和文件名字:
Paths.get("/Users/tommy0607","Downloads")
- 将路径进行任意分段:
Paths.get("/Users","tommy0607","Downloads")
当然除了绝对路径之外,相对路径也是可以的,例如Path.get("test/abc.png")
就是一个相对路径。
它还有一个重载方法,接受一个统一资源定位符URI为参数,在本节课中用不到就直接略过。
Path 对象的主要方法如下:
- getParent() 获取该路径的父路径
- isAbsolute() 返回该路径是否是绝对路径
- toAbsolutePath() 获取该路径的绝对路径
- normalize() 将绝对路径正常化,如果当前路径是相对路径则会报错
- toUri() 将其转换成URI
- resolve(String/Path other) 将当前路径与参数中的路径进行拼接,一般用于获取子目录的路径
- getFileName() 获取路径的文件名,分段路径的最右边那部分就是文件名
关于normalize()
方法的正常化我要特别讲解一下:
假设当前目录是 /Users/tommy0607/Downloads,现有这样的对象Path path = Paths.get("../../test.jar")
,
那么path.toAbsolutePath()
返回的路径是 /Users/tommy0607/Downloads/../../test.jar,而path.toAbsolutePath().normoalize()
返回的路径则是 /User/test.jar,也就是它会把绝对路径里所有的"../"都去掉,这就是正常化
还有要特别注意的就是 Path 类进行的所有操作都只是基于字符串处理,也就是说如果你输入一个不存在的路径也不会有任何的报错,这点要多加注意!
二、文件本身
虽然 Java 中也有用来代表一个文件(包括RegularFile和Directory)的类,叫做 File,但这个类的设计有些问题,它与其说是文件还不如说是路径,更何况在 Java7 之后就基本用不到这个类了,现在用得比较多的是 nio 中的 Files 类。
Files 工具类包含一系列完整的方法用于获得路径对应的文件的相关信息,下面用一个简单的示例来展示:
import java.nio.file.*;
import java.io.IOException;
public class PathAnalysis {
public static void main(String[] args) throws IOException {
Path p = Paths.get("PathAnalysis.java").toAbsolutePath();
say("该文件存在", Files.exists(p));
say("该文件是目录", Files.isDirectory(p));
say("该文件可执行", Files.isExecutable(p));
say("该文件可读", Files.isReadable(p));
say("该文件是RegularFile", Files.isRegularFile(p));
say("该文件可写", Files.isWritable(p));
say("该文件不存在", Files.notExists(p));
say("该文件是隐藏的", Files.isHidden(p));
say("文件尺寸", Files.size(p));
say("文件最后修改日期: ", Files.getLastModifiedTime(p));
say("文件拥有者", Files.getOwner(p));
}
static void say(String id, Object result) {
System.out.print(id + ": ");
System.out.println(result);
}
}
三、文件查找
在查找文件方面,Java 也提供了非常方便实用的API。但在介绍文件查找之前不得不先介绍一下Files.walk()
方法:它可以返回某个路径下所有子目录和文件的路径的流Stream<Path>
。举个例子:
Files.walk(Paths.get("/Users/tommy0607/Downloads"))
.forEach(System.out::println);
它输出了/Users/tommy0607/Downloads 下面所有子目录和文件的路径(包括当前目录本身)
文件查找有两种模式,glob 和 regex,这里只讲解前一种模式,后一种是使用正则表达式,感兴趣的同学可以自学。
在这里,我们使用 glob
查找以 .tmp
或 .txt
结尾的所有路径:
import java.nio.file.*;
public class Find {
public static void main(String[] args) throws Exception {
Path test = Paths.get("test");
// 创建一个叫dir.tmp的文件夹
Files.createDirectory(test.resolve("dir.tmp"));
PathMatcher matcher = FileSystems.getDefault()
.getPathMatcher("glob:**/*.{tmp,txt}");
Files.walk(test)
.filter(matcher::matches)
.forEach(System.out::println);
System.out.println("***************");
PathMatcher matcher2 = FileSystems.getDefault()
.getPathMatcher("glob:*.tmp");
Files.walk(test)
.map(Path::getFileName)
.filter(matcher2::matches)
.forEach(System.out::println);
System.out.println("***************");
Files.walk(test)
.filter(Files::isRegularFile) //只查找RegularFile
.map(Path::getFileName)
.filter(matcher2::matches)
.forEach(System.out::println);
}
}
/* 输出:
test\bag\foo\bar\baz\5208762845883213974.tmp
test\bag\foo\bar\baz\File.txt
test\bar\baz\bag\foo\7918367201207778677.tmp
test\bar\baz\bag\foo\File.txt
test\baz\bag\foo\bar\8016595521026696632.tmp
test\baz\bag\foo\bar\File.txt
test\dir.tmp
test\foo\bar\baz\bag\5832319279813617280.tmp
test\foo\bar\baz\bag\File.txt
***************
5208762845883213974.tmp
7918367201207778677.tmp
8016595521026696632.tmp
dir.tmp
5832319279813617280.tmp
***************
5208762845883213974.tmp
7918367201207778677.tmp
8016595521026696632.tmp
5832319279813617280.tmp
*/
在 matcher
中,glob
表达式开头的 **/
表示“当前目录及所有子目录”,这在不仅仅要匹配当前目录下的路径时非常有用。一个 *
表示“任何字符串”,然后是一个点,接着是大括号,表示一系列的可能性——我们正在寻找以 .tmp
或 .txt
结尾的路径。
matcher2
只使用 *.tmp
,按理来说应该无法匹配任何路径的,但注意看map()
方法将全路径转换成了文件名
注意,在前两种情况下,输出中都会出现 dir.tmp
,即使它是一个目录而不是一个文件(RegularFile)。如果只查找文件,必须像在最后的 files.walk()
中那样对其进行筛选。
四、文件读写
到了最后一部分才讲到本课程的核心主题——文件读写,不过用起来其实非常简单方便。
读文件
如果一个文件很小,也就是说就算把整个文件一次性全部读到内存中也没问题,那么可以用Files.readAllLines()
和Files.readAllBytes()
,前者是返回所有行的字符串,后者则是获取返回整个文件的字节数组。
但如果文件很大,一次性读取到内存中不太可能,或者说你并不需要读取整个文本,Files.lines()
方便地将文件转换为行的 Stream
(也就是说流中的元素是文件的每一行文本):
import java.nio.file.*;
public class ReadLineStream {
public static void main(String[] args) throws Exception {
Files.lines(Paths.get("PathInfo.java"))
.skip(13)
.findFirst()
.ifPresent(System.out::println);
}
}
代码很容易理解,先跳过 PathInfo.java 的前13行,然后读取接下来的一行并输出。
写文件
写文件就更简单了,直接调用Files.write()
即可,支持写入字节数组和一行一行的字符串;
或者Files.writeString()
,只写入一个字符串对象。这两个方法中都有的 Charset 类型的参数是写入的字符串的编码,如果不指定的话默认就是UTF-8编码。