Java-9_1-输入输出处理

本篇将介绍如何通过输入输出流以二进制流的形式操作文件、网络数据。

A. 字节流

在 Java API 中,我们通过 输入流读取字节,输出流处理字节输出。此外,使用 readers,writers 处理字符序列的读取写入。

需要注意的是,这里的流与处理集合类的 Stream 没有关系。

a. 流的获取

介绍三种获取流的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 从文件获取流,使用 final 类 Files 的静态方法
InputStream in = Files.newInputStream(path);
OutputStream out = Files.newOutputStream(path);

// 从网络URL 获取流
URL url = new URL("http://horstmann.com/index.html")
InputStream in = url.openStream();

// 从字符串读取流
byte[] bytes = ...;
InputStream in = new ByteArrayInputStream(bytes);
// 字符串输出流
ByteArrayOutputStream out = new ByteArrayOutputStream();
write to out ...
byte[] bytes = out.toByteArray();

b. 从输入流读取字节&向输出流写入字节

从输入流读取字节如下:

1
2
3
4
5
InputStream in = ...;
int b = in.read(); // 返回byte对应的 0-255之间的整数或 -1(没有内容)
byte[] bytes = in.readAllBytes(); // 读取所有字节
// 输入流保存到文件
Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);

向输出流写入字节,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
OutputStream out = ...;
int b = ..;
out.write(b); // 向 write 写入一个字节
byte[] bytes = ...;
out.write(bytes); //一次性向 out 写入一组字节
in.transferTo(out); // 从输入流传入
Files.copy(path,out); // 将文件内容放入输出流
/**
* 对于输出流,当完成写入时,需要 close 它,来完成输出的提交
* 可以使用 try ... catch ... 框架来实现
*/
try(OutputStream out = ..){
out.write(bytes);
}

B. 字符流

a. 编码问题

InputStream 和 OutputStream 都是处理的字节的问题,但是实际中,我们基本都是处理的文本内容。因此,编码问题很重要,涉及到文件能否正确读取。

Java 使用 Unicode标准(Unicode standard) 来处理字符。每个字符或者说“code point” 为21-bit 的整数。针对这一标准,有不同的字符编码方式来处理这 21个位(character encodings)。

最常用的为 UTF-8 编码方式(encoding),它将每个 Unicode 定义的 code point 视为 1到4个 字节(bytes)。

另一种常用的为 UTF-16 编码方式,它将 Unicode 定义的 code point 分成 1到2 个16位的值。Java 的 String 就默认使用这种编码方式。此外,UTF-16 还分为 “big-endian” 和 “little-endian” 两种,区别是表示字符的2个 bytes 放置的前后顺序不同。

Note: 一些软件在 UTF-8 编码的文档头部添加一个字节顺序标志,(例如微软的 notepad)对于 UTF-8 来说,这通常是不必要的,(但Unicode 标准推荐这么做)。对于 Java 程序来说,读取这类文档需要注意。

构建字符串时,我们可以用如下形式规定编码方式:

1
2
// bytes 为字符数组
String str = new String(bytes, StandardCharsets.UTF_8);

StandardCharsets 包含所有java 虚拟机支持的8中编码格式。

b. 读取、写入字符

不同于 InputStream 和 OutputStream 处理 字节 对象。我们可以利用 Reader, Writer, 以及Files, String 等的一些方法进行直接的 字符 的处理,需要注意的是,处理字符时,通常要传入使用的编码格式。

Reader

读取内容,我们可以利用 Reader 对象来实现,通过向 Reder 对象中传入 输入流对象(InputStream),并指明编码方式,实现内容的正确读取。如下:

1
2
3
4
5
6
7
InputStream inStream = ...;
Reader in = new InputStreamReader(inStream, charset);
// 读取内容
int ch = in.read();
// 若上面使用 UTF-16 格式,这里返回 0 到 65536 之间的一个值。(or -1)
// 使用 Files 方法的静态方法 来生成 Reader 对象
Reader in = Files.newBufferedReader(path, charset);

更方便的,我们可以直接从 文件中读取内容到 程序中,可以使用如下几种方式:

1
2
3
4
5
6
// 输入到字符串
String content = new String(Files.readAllBytes(path), charset);
// 输入到字符串列表
List<String> lines = Files.readAllLines(path, charset);
// 输入到字符串流
Stream<String> lines = Files.lines(path, charset);

需要注意的是,由于读取文件可能出现 IOException, 因此需要使用 try … catch 语句包裹读取文件的代码。

如果我们想将 String 切割为 单个元素,可以利用 Scanner 读取数字和单词,例子如下:

1
2
3
4
5
6
7
Scanner in = new Scanner(path, "UTF-8");
while(in.hasNextDouble()){
double val = in.nextDouble();
}
// 对于 Scanner,关键的是 它的 delimiter (默认的 delimiter 是 whitespace)
// 我们可以设置 Scanner 的 delimiter
in.useDelimiter("\\PL+"); // 所有非字母的字符为分隔符

如果不是从文件中读取,我们可以利用 BufferedReader 来包裹 InputStreamReader, 来实现字符的成块处理以提升效率。例子如下:

1
2
3
4
5
try(BufferedReader reader
= new BufferedReader(new InputStreamReader(url.openStream()))){
Stream<String> lines = reader.lines();
...
}

Writer

实现文本的输出,使用 Writer。我们可以通过如下手段 构造 Writer 对象。

1
2
3
4
5
6
// 利用已有的输出流 构造 writer 
OutputStream outStream = ...;
Writer out = new OutputStreamWriter(outStream, charset);
out.write(str);
// 直接指定输出的文件,利用 Files 的构造 方法构造
Writer out = Files.newBufferedWriter(path, charset);

我们也可以利用 PrinterWriter 来包裹 Writer 对象,从而可以利用 PrinterWriter 的 print() 等方法。

1
PrintWriter out = new PrintWriter(Files.newBufferedWriter(path, charset));

Note: 由于历史原因, System.outPrintStream 的一个实例,而不是 PrintWriter 的实例。

此外,类似 Reader,我们也可以直接向文件输出内容,使用 Files 的静态方法。

1
2
3
4
5
6
String content = ...;
Files.write(path, content.getBytes(charset));
// lines 为 Collection<String> 类型对象
Files.write(path, lines, charset);
// 向文档尾部追加内容
Files.write(path, content.getBytes(charset), StandardOpenOption.APPEND);

C. 其他形式的读写&注意事项

a.读取二进制数据

推荐使用 DataInputDataOutput 接口来读取和输出纯数值类型的数据(基本数据类型的)。因为这些数据具有固定的字节长度,使用 DataInput/DataOutput 进行读写效率更高。

他们的主要方法有:byte readByte(), int readUnsignedByte(), int readInt()

可以使用 DataInputStream/DataOutputStream 来包裹 stream 类型的数据

b. 随机读写

RandomAccessFile 类让我们在文件的任意位置处 读取或者写入 数据。通过 new 方法生成一个 该类的对象,例子如下:

1
RandomAccessFile file = new RandomAccessFile(path.toString(), "rw");

传入构造函数的第二个参数表明是只允许读(使用 “r”),还是同时允许读写。

random access 的文件拥有一个 文件指针(file pointer),来标记 读写的位置。 可以通过向 seek() 方法传入一个在 0 和文档长度之间的 long 型整数来设定 文件指针的位置。getFilePointer 可以返回当前的文件指针的位置。

此外,RandomAccessFile 类实现了 DataInputDataOutput 两个接口,因此可以使用这两个接口中的方法。

另一种随机读写的思路是通过将文件数据载入到内存中进行读写,以实现更加高效的操作。它的实现利用FileChannel 类来打开文件,然后通过 FileChannel 的实例方法 map() 将数据映射到 ByteBuffer (继承了 Buffer 类) 等对象的实例中(应该也就是内存中),再利用 buffer 对象进行读写(get, put 方法)。

c. 文件锁

当我们有并行的程序处理同一个文件时,可能发生修改冲突而导致文件损坏。这是,我们需要使用文件锁(File locks)来保证文件的安全处理。

为文件加锁的流程如下:

1
2
3
4
FileChannel channel = FileChannel.open(path);
FileLock lock = channel.lock(); // block until get the lock
//return immediately, either with a lock or null
FileLock lock = channel.tryLock();

类似打开文件,写入文件,推荐使用 try 方法来处理加锁过程。

D. 路径、文件与目录

a. Path 对象

我们可以使用 Paths 类的静态方法 类构建一个 Path 对象,如下:

1
2
3
4
// 自动使用系统的路径分隔符来构建路径,UNIX - '/', Windows- '\'
Path.absolute = Paths.get("/", "home", "cay");
// 直接给出完整路径
Path homeDir = Paths.get("/home/cay");

针对路径 Path,有很多的实例方法,例如:

  • resolve(), 向路径尾部追加路径,
  • resolveSibling() 替换最尾部的一级路径;
  • relativize()类似一个求差操作;
  • toAbsolutePath(), 得到path 的绝对路径;
  • getParent() 得到目路径…

此外, Path 接口 继承了 Iterable<Path> 接口,因此可以用增强型的 for 循环来 遍历 Path 中的成员元素(每一级路径)。

b. 创建文件/文件夹

利用 Path 对象,我们可以 创建文件或者 文件夹, 以及检查文件是否存在。

1
2
3
4
5
6
7
8
// 只能创建最尾部的文件夹
Files.createDirectory(path);
// 可以同时创建中间级文件夹(if not exist)
Files.createDirectories(path);
// 创建文件
Files.createFile(path);
// 检查给定的文件或路径是否存在
Files.exist(path);

此外,还可以通过 Files.createTempFile(), Files.createTempDirectory() 方法来创建临时文件。

c. 文件的移动、复制与删除

我们可以配合使用 Path 和 Files 的静态方法来复制,移动,和删除文件。如下:

1
2
3
4
5
6
// 复制
Files.copy(fromPath, toPath);
// 移动
Files.move(fromPath, toPath);
// 删除
Files.delete(path);

移动文件时要注意所移动的文件是否存在,以及移动到的地方是否有同名文件。

我们可以使用标准的文件操作 options 来规定文件移动时的行为,例如:

1
2
// atomic 模式移动,如果移动失败,保留文件在原地
Files.move(fromPath, toPath, StandardCopyOption.ATOMIC_MOVE);

这些标准行为参数 在 枚举类 StandardOpenOptionStandardCopyOption 中。

d. 遍历文件夹条目

我们可以使用下述方法对文件夹中的内容进行遍历:

1
2
3
4
// 只列path 指定路径的同一级文件和文件夹
Stream<Path> entries = Files.list(pathToDirectory);
// 遍历文件夹中的所有子文件,子文件夹(深度优先遍历)
Stream<Path> entries = Files.walk(pathToRoot);

需要注意的是,文件夹的读取涉及到系统资源的使用,因此,通常使用 try 方法进行包裹,来保证资源的顺利关闭。

此外,如果需要利用 文件夹的遍历方法进行文件的删除,上述 walk() 方法无法使用,需要使用 FileVisitor 接口的 walkFileTree() 方法。(具体例子略。需要重写 FileVisitor 接口中的一些方法)

此外,Java 还提供了方法来直接遍历压缩文件 - zip file。例子如下:

1
2
3
4
5
// zipname 为压缩文件的文件名
// 新建一个文件系统 包含压缩文件中的所有文件
FileSystem zipfs = FileSystems.newFileSystem(Paths.get(zipname), null);
// 复制压缩文件中的文件. sourceName 为需要复制的文件的文件名
Files.copy(zipfs.getPath(sourceName), targetPath);

E. HTTP 连接

在之前的例子中,我们看到可以使用 URL 来获取输入流,但是,如果需要关于网络传输的更多信息,URL 类通常难以胜任。实际上,Java 中的 URLConnection 类在 Http 协议广泛应用之前就已经出现,因此,它对 网络协议的很多支持显得非常笨重。

在 Java 9 中,java 中新加入了 HttpClient 类(在 java 9中, 该类有点类似公测状态,到 Java 11中,已经基本成熟,该类位于 Java.net.http 包中)来提共对于 HTTP/2 协议更好的支持。

a. 使用 URLConnection 完成网络交互

我们逐步介绍使用 URLConnection 进行 网络 request 与 response 读取的步骤。

1- 获取一个 URL 对象。

1
URLConnection connection = url.openConnection();

对于一个 HTTP 的 URL,返回的对象实际上是 HttpURLConnection 的实例。

2- 如果需要,设置 request 的参数。

1
connection.setRequestProperty("Accept-charset", "UTF-8, ISO-8859-1");

3- 将数据发送至 服务器。

1
2
3
4
connection.setDoOutput(true);
try(OutputStream out = connection.getOutputStream()){
//write to out
}

4- 如果没有调用 getOutputStream 而想要读取 response 的头。

1
2
connection.connect();
Map<String, List<String>> headers = connection.getHeaderFields();

5- 读取 response。

1
2
3
try(InputStream in = connection.getInputStream()){
// read from in
}

b. HTTP Client API

从 Java 9 开始,java提供 了 HTTP client 来更加方便的处理网络相关的操作。下面介绍 HttpClient 类的使用方法。

  1. 新建 HttpClient 的对象。
1
2
3
4
5
HttpClient client = HttpClient.newHttpClient();
// 使用 builder 方法进行构建
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();

对于 builder 方法,首先调用方法对 待构建的对象的 特定属性 进行定制化处理,最后调用 build() 方法完成构造。

  1. 新建 Request。
1
2
3
4
5
6
7
8
9
10
11
12
// Get request
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://horstmann.com"))
.GET()
.build();
// Post request
StringBuilder body = ....;
HttpRequest request = HttpRequest.newBuilder()
.uri(httpUrlString)
.header("Content-type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyProcessor.fromString(body.toString()))
.build();

uri 是 uniform resource identifier 的简称,对于 Http,它与 URL 相同。 在 java 中,URL可以用于生成连接,而 uri 只规定了语法格式。

在 POST 中,我们传入了一个 post 的内容,该内容可以是 很多格式(一般的为 JSON 格式),因此,在参数中,上面长串的方法 用于指明具体的格式。(例子中为 string 格式)

  1. 处理 response。

发送请求时,我们需要告知 client 如何处理 reponse。尤其地,要指明 response 的 body 以格式进行处理。这里,我们将 reponse body 以 String 格式进行处理,代码如下:

1
2
HTTPResponse<String> response 
= client.send(request, HttpResponse.BodyHandler.asString());