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 – 包装流
- 阻塞模式(blocking mode) – 等待完整的消息
2.2. NIO – java.nio
java.nio 包在Java 1.4中被引入 并在 Java 1.7 (NIO.2) 更新了,其中包含 增强的文件操作 和 ASynchronousSocketChannel。它提供 :
- Buffer – 一个读取数据块
- CharsetDecoder – 用于将原始字节映射到可读字符/从可读字符映射原始字节
- Channel – 与外界沟通
- Selector – 在 SelectableChannel 上启用多路复用,并提供对任何准备好进行I/O的 Channels 的访问
- 非阻塞模式(non-blocking mode) – 读取任何准备好的东西
现在,让我们看看在向服务器发送数据或读取其响应时如何使用这些包。
3. 配置测试服务器
在这里,我们将使用 WireMock 来模拟另一台服务器,以便我们可以独立运行测试。
配置这台服务器来监听请求,并像真正的web服务器一样向我们发送响应。同时我们还将使用动态端口,这样就不会与本地计算机上的任何服务冲突。
让我们添加WireMock Maven依赖项到 test scope:
Let's add the Maven dependency for WireMock with test scope:
- <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. Blocking IO – java.io
我们可通过从网站上读取一些数据来了解原始的阻塞IO模型是如何工作的,例如:使用一个 java.net.Socket 来访问操作系统的一个端口。
4.1. 发送请求(Request)
在这个例子中,我们将创建一个GET请求来检索资源。首先,创建一个 Socket 来访问我们的WireMock服务器正在监听的端口:
- Socket socket = new Socket("localhost", wireMockRule.port()
对于普通的 HTTP 或 HTTPS 通信,端口应该是 80 或 443 。但是,在本例中,我们使用wireMockRule.port() 来访问前面设置的动态端口。现在,我们在套接字上打开一个 OutputStream ,包装在 OutputStreamWriter 中,并将其传递给 PrintWiter 来编写我们的消息。确保刷新缓冲区以便发送我们的请求:
- 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. 等待响应(Response)
打开套接字上的 InputStream 来获取响应,使用 BufferedReader 读取流,并将其存储在 StringBuilder 中:
- InputStream serverInput = socket.getInputStream();
- BufferedReader reader = new BufferedReader(new InputStreamReader(serverInput));
- StringBuilder ourStore = new StringBuilder();
我们使用 reader.readLine() 来阻塞,等待一个完整的行,然后将该行追加到我们的存储中。我们将一直读取,直到得到一个空值,它指示流的结尾:
- for (String line; (line = reader.readLine()) != null;) {
- ourStore.append(line);
- ourStore.append(System.lineSeparator());
- }
5. Non-Blocking IO – java.nio
现在,让我们看看 NIO包 的非阻塞IO模型是如何与同一个例子一起工作的。
这次,我们将创建一个 java.nio.channel.SocketChannel 来访问服务器上的端口,而不是java.net.Socket,并向它传递一个InetSocketAddress。
5.1. 发送 Request
首先, 打开 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. 读取 Response
发送请求后,我们可以使用原始缓冲区以非阻塞模式读取响应。
既然要处理文本,那么我们需要一个 ByteBuffer 来处理原始字节,一个CharBuffer 用来转换字符(借助 CharsetDecoder):
- ByteBuffer byteBuffer = ByteBuffer.allocate(8192);
- CharsetDecoder charsetDecoder = charset.newDecoder();
- CharBuffer charBuffer = CharBuffer.allocate(8192);
如果数据是以多字节字符集发送的,CharBuffer 将有剩余空间。
注意,如果需要特别快的性能,我们可以使用 ByteBuffer.allocateDirect() 在本机内存中创建一个MappedByteBuffer。然而,在我们的例子中,从标准堆中使用 allocate() 已经足够快了。
在处理缓冲区时,我们需要知道缓冲区有多大(capacity),我们在缓冲区中的位置(current position),以及我们能走多远(limit)。
所以,我们从SocketChannel中读取,将它传递给 ByteBuffer 来存储我们的数据。从 SocketChannel读取将以 ByteBuffer的当前位置为下一个要写入的字节(就在写入最后一个字节之后)结束,但其限制(limit)不变:
- socketChannel.read(byteBuffer)
Our SocketChannel.read() 返回可以写入缓冲区的读取字节数 ,如果断开连接,则会变成 -1.
当缓冲区由于尚未处理其所有数据而没有剩余空间时,SocketChannel.read() 将返回读取的零字节,但buffer.position() 仍将大于零。
确保从缓冲区的正确位置开始读取, 我们将使用 Buffer.flip() 来设置 ByteBuffer 的当前位置为0 以及它对 SocketChannel 写入的最后一个字节的限制。然后,我们将使用 storeBufferContents 方法保存缓冲区内容,稍后我们将查看该方法。最后,使用 buffer.compact() 压缩缓冲区并设置当前位置,以便下次从 SocketChannel 读取。
由于数据可能部分到达,需要用终止条件将缓冲区读取代码包装成一个循环,以检查套接字是否仍然连接,或者是否已断开连接,但缓冲区中仍有数据:
- while (socketChannel.read(byteBuffer) != -1 || byteBuffer.position() > 0) {
- byteBuffer.flip();
- storeBufferContents(byteBuffer, charBuffer, charsetDecoder, ourStore);
- byteBuffer.compact();
- }
别忘了关闭套接字(除非我们在try with resources块中打开它):
- socketChannel.close();
5.3. Buffer存储数据
来自服务器的响应将包含头,这可能会使数据量超过缓冲区的大小。因此,我们将使用StringBuilder在消息到达时构建完整的消息。为了存储我们的消息,我们首先将原始字节解码为我们的 CharBuffer 中的字符。然后翻转指针,以便读取字符数据,并将其附加到可扩展的 StringBuilder. 最后,清除CharBuffer以准备下一个写/读循环。现在,让我们实现传入缓冲区的完整 storeBufferContents() 方法,CharsetDecoder 和 StringBuilder:
- void storeBufferContents(ByteBuffer byteBuffer, CharBuffer charBuffer,
- CharsetDecoder charsetDecoder, StringBuilder ourStore) {
- charsetDecoder.decode(byteBuffer, charBuffer, true);
- charBuffer.flip();
- ourStore.append(charBuffer);
- charBuffer.clear();
- }
6. 总结
本文中, 我们已经看到原始java.io模型如何阻塞,等待请求,并使用 Streams 来操作它接收到的数据。相反,java.nio库允许使用Buffers和Channels进行非阻塞通信,并且可以提供直接内存访问以获得更快的性能。然而,这种速度带来了处理缓冲区的额外复杂性。
在本文中,我们看到了原始 java.io 模型如何阻塞,如何等待请求并使用Streams来处理它接收到的数据。相反,java.nio库允许使用Buffers和Channels进行非阻塞通信,并且可以提供直接内存访问以获得更快的性能。然而,这种速度带来了处理缓冲区的额外复杂性。
一如既往, 代码 over on GitHub.