Java 网络编程——如何利用网络 I/O 收发数据

java.net、java.io、java.nio

Java 作为互联网领域的主流编程语言,经常被用于处理网络 I/O。尽管在大多数情况下,我们会使用各种封装好的工具,但了解原生的 API 仍然很必要。毕竟,当我们评估一个工具是否能够满足需求时,仍需通过深入分析这些工具的内部实现来做出评判。

本文内容基于 JDK 1.8,主要涉及 java.netjava.iojava.nio 三个包。

1 java.net 核心 API

java.net 是 Java 网络编程的基础,它内部完成了对 Socket 套接字操作、网络协议处理过程的封装。接下来,我们将通过三个示例详细介绍如何使用 Java 进行 TCPUDP 和 HTTP 协议的数据收发。

1.1 TCP 协议

首先是 TCP 服务端。有过 Linux C 开发经验的同学都知道,C 语言的 TCP Server 实现起来相对繁琐,需要先创建 socket 以及复杂的 sockaddr_in 结构体,然后进行 bindlisten 调用,才能接收客户端请求。Java 的网络 API 对 C 语言中的这些函数进行了封装,我们只需创建一个 ServerSocket,JVM 便会自动完成这些繁琐工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 1. 建立网络连接
try (ServerSocket serverSocket = new ServerSocket(80)) {
    while (true) {
        // 2. 阻塞等待客户端连接
        Socket clientSocket = serverSocket.accept();
        // 3. 收到连接请求
        System.out.println(clientSocket.getInetAddress().getHostAddress()
                + ":" + clientSocket.getPort());
    }
} catch (IOException e) {
    throw new RuntimeException(e);
}

客户端的 API 也类似,只需要创建一个 Socket 对象即可自动连接服务端:

1
2
3
4
5
6
// 1. 连接服务器
try (Socket socket = new Socket("127.0.0.1", 80)) {
    // 2. 断开连接
} catch (IOException e) {
    throw new RuntimeException(e);
}

客户端连接后,服务端成功打印出客户端的 ip + 端口,表示连接成功。

1.2 UDP 协议

java.net 中与 UDP 相关的类如下:

类名说明
DatagramSocket数据报套接字
DatagramPacket数据报传输数据包

UDP 使用起来更为简单,以下是服务端代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 1. 创建 UDP 套接字
try (DatagramSocket serverSocket = new DatagramSocket(90)) {
    while (true) {
        // 2. 注册数据报容器
        byte[] buffer = new byte[1024];
        DatagramPacket receivePacket = new DatagramPacket(buffer, buffer.length);
        // 3. 阻塞等待客户端发送的数据包
        serverSocket.receive(receivePacket);
        // 4. 数据到达
        String clientMessage = new String(receivePacket.getData(), 0, receivePacket.getLength());
        System.out.println("msg from client: " + clientMessage);
    }
} catch (IOException e) {
    throw new RuntimeException(e);
}

客户端示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 1. 创建 UDP 套接字
try (DatagramSocket clientSocket = new DatagramSocket()) {
    byte[] sendData = "Hello, UDP Server!".getBytes(StandardCharsets.UTF_8);
    // 2. 直接发送数据包
    DatagramPacket sendPacket = new DatagramPacket(sendData,
            sendData.length,
            InetAddress.getByName("127.0.0.1"), 90);
    clientSocket.send(sendPacket);
} catch (IOException e) {
    throw new RuntimeException(e);
}

UDP 不需要像 TCP 一样建立连接,只要知道服务端的 IP 与端口,就可以直接发送数据。而且数据会直接通过 DatagramPacket 发送,不需要 I/O 工具介入。

1.3 HTTP(S) 协议

记得当年开发嵌入式程序时,由于芯片厂商给的 SDK 太过简陋,我不得不手动封装 HTTP 处理服务(没错,HTTP 协议的本质是在传输层基础上添加了请求头和请求体等文本段)。

从事 Java 开发后就幸福多了,JDK 已经在其网络库中提供了 HTTP(S) 协议解析工具:

HTTP 相关说明
java.net.HttpURLConnection由 Java 基础库提供的 HTTP 连接抽象类,该类定义了基础的 HTTP 状态码以及对 HTTP 连接操作的标准接口
sun.net.www.protocol.http.HttpURLConnectionHTTP 连接实现类,由 sun.net 提供
HTTPS 相关说明
javax.net.ssl.HttpsURLConnection由 Java 扩展库提供的 HTTPS 连接抽象类,继承自 HttpURLConnection,扩展了 HTTPS 相关的功能
sun.net.www.protocol.https.DelegateHttpsURLConnectionHTTPS 连接委托类,由 sun.net 提供,内部通过 HttpsURLConnectionImpl 对象完成对 HTTPS 连接的操作
sun.net.www.protocol.https.HttpsURLConnectionImplHTTPS 连接实现类,由 sun.net 提供,通常不会直接使用,而是通过委托类 DelegateHttpsURLConnection 调用

下面我们通过上述 API 完成对本站首页的访问:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
try {
    URL obj = new URL("https://maling.io/");
    // 1. 打开一个 HTTPS 连接
    HttpURLConnection con = (HttpURLConnection) obj.openConnection();
    // 2. 设置请求方法
    con.setRequestMethod("GET");
    // 3. 获取响应代码
    int responseCode = con.getResponseCode();
    System.out.println("code: " + responseCode);
    // 4. 读取响应内容
    try (BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()))) {
        String inputLine;
        StringBuilder response = new StringBuilder();
        while ((inputLine = in.readLine()) != null) {
            response.append(inputLine);
        }
        System.out.println("data: " + response);
    }
} catch (IOException e) {
    throw new RuntimeException(e);
}

结果如下:

1
2
code: 200
data: <!doctype html><html itemscope itemtype="http://schema.org/WebPage" lang="zh-CN"><head><me...

表示 HTTP 请求成功。

2 java.io 核心 API

与 UDP 可以通过 DatagramPacket 直接进行数据的收发不同,TCP 协议或者基于 TCP 的应用层协议是面向数据流的,因此需要引入 I/O 的概念。

在 java.io 中,与网络相关的类主要有以下几个:

类名说明
SocketInputStream & SocketOutputStream用于读写网络套接字
ObjectInputStream & ObjectOutputStream通过网络传输对象的序列化形式
BufferedReader & BufferedWriter提供了缓冲功能,可以提高读写的效率
PrintWriter用于将文本写入网络输出流,简化操作

实际上,java.io 是一个庞大的装饰系统,不过本文并不打算做官方文档 Copy,这里就不再展开了。

3 java.nio 核心 API

java.nio 即 “New I/O”,它引入了诸如 ChannelBufferSelector 等现代 API,这些 API 封装了操作系统层面的高性能系统调用。关于具体的 I/O 模型与使用示例,可以访问这篇文章:多路复用 I/O 模型

接下来,我们详细介绍一下 java.nio 中常用的 API。

3.1 Buffer (缓冲区)

Buffer 本质上是可读可写的内存块,它提供了简化内存操作的方法,并通过属性记录缓冲区的状态变化。

3.1.1 Buffer 类及其子类

Buffer 类及其子类

由图可知,Buffer 有很多实现类,例如:ByteBufferCharBufferLongBuffer 等,分别用于处理不同的数据类型,以提高性能。

3.1.2 缓冲区对象创建

以上所有类型的 Buffer 都支持以下两种方法创建:

方法名说明
allocate()创建一个新 buffer
wrap(double[] array, ...)根据现有内容创建一个缓冲区

其中 ByteBuffer 比较特殊,它有两种不同的缓冲区:

  • 直接缓冲区:在系统内核缓冲中分配的缓冲区,通过 allocateDirect() 方法分配,可以直接操作 JVM 堆外内存;
  • 非直接缓冲区:普通的 JVM 堆内缓冲区,通过 allocate() 方法分配。

3.1.3 向缓冲区添加数据

方法名说明
XxxBuffer put(..)向各类 Buffer 中添加数据
int position()/Buffer position(int newPosition)Buffer 基类规定的方法,用于获得当前要操作的索引/修改当前要操作的索引位置
int limit()/Buffer limit(int newLimit)Buffer 基类规定的方法,用于查询最多能操作到哪个索引/修改最多能操作的索引位置
int capacity()Buffer 基类规定的方法,返回缓冲区的总长度
int remaining()/boolean hasRemaining()Buffer 基类规定的方法,查询还有多少能操作的索引/查询是否还能操作

写操作图解:

nio buffer 写操作图解

3.1.4 读取缓冲区数据

方法名说明
get()读取一个单位类型数据
flip()反转缓冲区,将 limit 设置为 position,再将 position 设为 0

常用于写入数据后将 Buffer 切换为读模式
get(int index)读指定索引处的单位数据
rewind()position 置为 0,用于重复读取
clear()初始化缓冲区,将 position 设为 0,limit 设置为最大容量capacity,同时保留 Buffer 内的数据

常用于读取数据后将 Buffer 切换为写模式
array()将缓冲区转换成数组 char[] 返回

flip() 方法图解:

nio buffer flip() 方法图解

clear() 方法图解:

nio buffer clear() 方法图解

3.2 Channel (通道)

Channel 是一个全双工读写通道,同时支持阻塞和非阻塞模式。它类似于 I/O 流,但也有一些不同之处:

  • Channel 可读可写全双工,而流一般来说是单向的,需要区分输入流和输出流;
  • Channel 支持异步读写;
  • Channel 总是基于 Buffer 读写。

java.nio 提供了四类 Channel,分别是:

  • XxxFileChannel:用于文件操作;
  • XxxSocketChannel:用于客户端 TCP 操作;
  • XxxServerSocketChannel:用于服务端 TCP 操作;
  • DatagramChannel:用于 UDP 操作。

3.3 Selector (选择器)

Selector 用于持续轮询注册在其上的 Channel,以选择并分发已处理的就绪事件。多路复用 I/O 模型里的事件有以下四种:

  • 连接事件;
  • 接收事件;
  • 可读事件;
  • 可写事件。

Selector 可以同时轮训和监控多个 Channel,当 Selector 发现某个 Channel 的数据状态发生变化时,会通过 SelectorKey 触发相关事件,并由监听此事件的事件处理器来执行相关逻辑。其常用 API 如下:

  • Selector 抽象类:

    方法名说明
    Selector open()获取一个 Selector 对象
    int select()阻塞监控所有注册的 Channel,当有对应事件发生,会将 SelectorKey 放入集合内部并返回事件数量
    int select(long timeout)带超时的阻塞监听
    selectedKeys()返回存有 SelectorKey 的集合
  • SelectionKey 抽象类

    方法名说明对应事件属性
    isAcceptable()是否是连接继续事件SelectionKey.OP_ACCEPT
    isConnectable()是否是连接就绪事件SelectionKey.OP_CONNECT
    isReadable()是否是可读事件SelectionKey.OP_READ
    isWritable()是否是可写事件SelectionKey.OP_WRITE


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容