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() && path.getFileName().toString().startsWith("application") );
查找 2019 年 3 月 16 日之后创建的所有目录:
Stream<Path> resultAsStream = Files.find( startPath, Integer.MAX_VALUE, (path, attr) -> attr.isDirectory() && attr.creationTime().toInstant() .isAfter(LocalDate.of(2019, 3, 16).atStartOfDay() .toInstant(ZoneOffset.UTC)) );
如果我们喜欢将约束表示为表达式(例如,正则表达式),那么我们可以使用PathMatcher接口。这个接口附带了一个名为matches(Path path)的方法,它可以判断给定的路径是否匹配这个匹配器的模式。
FileSystem实现通过FileSystem.getPathMatcher(String syntaxPattern)支持 glob 和 regex 语法(也可能支持其他语法)。约束采用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) && !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类的工作,即InputStream和OutputStream。对于原始二进制数据的流文件,我们关注于一次读/写一个字节(8 位)的FileInputStream和FileOutputStream类。对于著名的二进制数据类型,我们也有专门的类(例如,音频文件应该通过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。
这就是缓冲流进入场景的地方。想想当我们在线观看视频时会发生什么。当我们观看视频时,浏览器正在提前缓冲传入的字节。这样,我们就有了一个平稳的体验,因为我们可以看到缓冲区中的字节,避免了在网络传输过程中看到字节可能造成的中断:
同样的原理也用于类,例如用于原始二进制流的BufferedInputStream、BufferedOutputStream和用于字符流的BufferedReader、BufferedWriter。其主要思想是在处理之前对数据进行缓冲。这一次,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),我们可以关注内存映射文件(MappedByteBuffer。MappedByteBuffer允许我们创建和修改巨大的文件,并将它们视为非常大的数组。它们看起来像是在记忆中,即使它们不是。一切都发生在本机级别:
// 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
。敬请期待!
写入文本文件
对于每个专用于读取文本文件的类/方法(例如,BufferedReader
和readString()
),Java 提供其对应的用于写入文本文件的类/方法(例如,BufferedWriter
和writeString()
)。下面是通过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)。有许多其他选项可用(例如,APPEND、DELETE_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 ... "); } }
前面的方法非常适合原始二进制数据,但有时二进制文件包含某些数据(例如,int、float等)。在这种情况下,DataInputStream和DataOutputStream为读写某些数据类型提供了方便的方法。假设我们有一个文件,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
类是读取某些类型数据的好选择。有关更多信息,请查看“使用扫描器”部分中的问题。
现在,让我们看看如何将二进制文件直接读入内存。