Java IO 与 NIO

49 阅读6分钟

1. 概述

处理输入和输出是 Java 程序员的常见任务。在本教程中,我们将介绍原始的java.io ( [IO]) 库和较新的java.nio  ( [NIO] ) 库,以及它们在网络通信时的区别。

2. 主要特点

让我们首先看一下这两个软件包的主要功能。

2.1. IO – java.io

java.io是在 Java 1.0 中引入的Reader 是在 Java 1.1 中引入的。它提供:

  • InputStream 和[OutputStream] – 每次提供一个字节的数据
  • Reader 和Writer  – 流的便捷包装器
  • 阻塞模式——等待完整的消息

2.2. NIO – java.nio

java.nio是在 Java 1.4 中引入的,并在 Java 1.7 (NIO.2) 中进行了更新,增强[了文件操作]和*[ASynchronousSocketChannel]*。它提供:

  • 缓冲区 —— 一次读取大块数据
  • CharsetDecoder  – 用于将原始字节映射到可读字符
  • 通道 ——与外界沟通
  • [选择器] ——在SelectableChannel上启用多路复用,并提供对任何已准备好进行 I/O 的Channel的访问
  • 非阻塞模式——读取已准备好的内容

现在让我们看看当我们向服务器发送数据或读取其响应时如何使用每个包。

3.配置我们的测试服务器

在这里我们将使用[WireMock]来模拟另一台服务器,以便我们可以独立运行测试。

我们将配置它来监听我们的请求并像真正的 Web 服务器一样向我们发送响应。我们还将使用动态端口,这样我们就不会与本地机器上的任何服务发生冲突。

让我们为具有测试范围的 WireMock 添加 Maven 依赖项:

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <version>2.26.3</version>
    <scope>test</scope>
</dependency>

在测试类中,我们定义一个 JUnit  @Rule来在空闲端口上启动 WireMock。然后,我们将其配置为在请求预定义资源时返回 HTTP 200 响应,消息正文为 JSON 格式的文本:

@Rule public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicPort());

private String REQUESTED_RESOURCE = "/test.json";

@Before
public void setup() {
    stubFor(get(urlEqualTo(REQUESTED_RESOURCE))
      .willReturn(aResponse()
      .withStatus(200)
      .withBody("{ "response" : "It worked!" }")));
}

现在我们已经设置了模拟服务器,我们可以运行一些测试了。

4. 阻塞 IO – java.io

让我们通过从网站读取一些数据来了解原始阻塞 IO 模型的工作原理。我们将使用java.net.Socket来访问操作系统的一个端口。

4.1. 发送请求

在此示例中,我们将创建一个 GET 请求来检索我们的资源。首先,让我们创建一个Socket来访问我们的 WireMock 服务器正在监听的端口:

Socket socket = new Socket("localhost", wireMockRule.port())复制

对于正常的 HTTP 或 HTTPS 通信,端口应该是 80 或 443。但是,在这种情况下,我们使用wireMockRule.port() 来访问我们之前设置的动态端口。

现在让我们在套接字上 打开一个OutputStream,将其包装在OutputStreamWriter中,并将其传递给PrintWriter以写入我们的消息。并确保刷新缓冲区以便发送我们的请求:

OutputStream clientOutput = socket.getOutputStream();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientOutput));
writer.print("GET " + TEST_JSON + " HTTP/1.0\r\n\r\n");
writer.flush();

4.2. 等待响应

让我们在套接字上 打开一个InputStream 来访问响应,用*[BufferedReader]读取流,并将其存储在StringBuilder*中:

InputStream serverInput = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));
StringBuilder ourStore = new StringBuilder();

让我们使用reader.readLine() 进行阻塞,等待完整的一行,然后将该行附加到我们的存储中。我们将继续阅读,直到得到 null  这表示流的结束:

for (String line; (line = reader.readLine()) != null;) {
   ourStore.append(line);
   ourStore.append(System.lineSeparator());
}

5. 非阻塞IO—— java.nio

现在我们通过同样的例子看一下nio包的非阻塞IO模型是如何工作的。

这次,我们将创建一个java.nio.channel.SocketChannel来 访问我们服务器上的端口 而不是java.net.Socket,并向其传递一个InetSocketAddress

5.1. 发送请求

首先,让我们打开 SocketChannel

InetSocketAddress address = new InetSocketAddress("localhost", wireMockRule.port());
SocketChannel socketChannel = SocketChannel.open(address);

现在,让我们获取一个标准的 UTF-8字符集来编码和编写我们的消息:

Charset charset = StandardCharsets.UTF_8;
socket.write(charset.encode(CharBuffer.wrap("GET " + REQUESTED_RESOURCE + " HTTP/1.0\r\n\r\n")));

5.2. 阅读响应

发送请求后,我们可以使用原始缓冲区以非阻塞模式读取响应。

因为我们要处理文本,所以我们需要一个ByteBuffer 用于保存原始字节,以及一个CharBuffer用于保存转换后的字符(由CharsetDecoder辅助):

ByteBuffer byteBuffer = ByteBuffer.allocate(8192);
CharsetDecoder charsetDecoder = charset.newDecoder();
CharBuffer charBuffer = CharBuffer.allocate(8192);复制

如果数据以多字节字符集发送,我们的CharBuffer将会有剩余空间。

请注意,如果我们需要特别快的性能,我们可以使用ByteBuffer.allocateDirect() 在本机内存中创建MappedByteBuffer。但是,在我们的例子中,使用标准堆中的*allocate()已经足够快了。*****

处理缓冲区时,我们需要知道缓冲区有多大(容量)、我们在缓冲区中的位置(当前位置)以及我们可以走多远(限制)。

因此,让我们从SocketChannel 读取数据**,并将ByteBuffer传递给它以存储数据。我们从SocketChannel 读取数据时, ByteBuffer的当前位置将设置为要写入的下一个字节(紧接着写入的最后一个字节),但其限制不变:************

socketChannel.read(byteBuffer)复制

我们的SocketChannel.read() 返回可写入缓冲区的读取字节数。如果套接字已断开连接,则返回 -1。

当我们的缓冲区没有任何剩余空间时,因为我们还没有处理所有数据,那么SocketChannel.read() 将返回零字节读取,但我们的buffer.position() 仍然大于零。

为了确保从缓冲区的正确位置开始读取,我们将使用**Buffer.flip () 将ByteBuffer的当前位置设置为零,并将其限制设置为 SocketChannel 写入的最后一个字节 **然后,我们将使用 storeBufferContents方法保存缓冲区内容,稍后我们将介绍该方法。最后,我们将使用buffer.compact() 压缩缓冲区并设置当前位置,以便下次从SocketChannel 读取。

因为我们的数据可能会分部分到达,所以我们将缓冲区读取代码包装在一个带有终止条件的循环中,以检查我们的套接字是否仍然连接,或者我们是否已断开连接但缓冲区中是否仍有数据:

while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) {
    byteBuffer.flip();
    storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore);
    byteBuffer.compact();
}

并且不要忘记close() 我们的套接字(除非我们在 try-with-resources 块中打开它):

socketChannel.close();

5.3. 存储缓冲区的数据

服务器的响应将包含标头,这可能会使数据量超出我们的缓冲区大小。因此,我们将使用 StringBuilder消息到达时构建完整的消息。

为了存储我们的消息,我们首先将原始字节解码为CharBuffer中的字符。然后我们将翻转指针,以便我们可以读取字符数据,并将其附加到可扩展的StringBuilder 中。 最后,我们将清除CharBuffer以准备下一个写入/读取周期。

现在,让我们实现完整的storeBufferContents() 方法,并传入缓冲区、CharsetDecoderStringBuilder

void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer, 
  CharsetDecoder charsetDecoder, StringBuilder ourStore) {
    charsetDecoder.decode(byteBuffer, charBuffer, true);
    charBuffer.flip();
    ourStore.append(charBuffer);
    charBuffer.clear();
}

六,结论

在本文中,我们了解了原始java.io模型如何阻塞、等待请求以及如何使用Stream来操作它接收到的数据。

相比之下,java.nio **库**允许使用BufferChannel 进行非阻塞通信,并可以提供直接内存访问以实现更快的性能。然而,这种速度也带来了处理缓冲区的额外复杂性。