杰瑞科技汇

Java Socket发送数据时,如何确保数据完整到达?

我将从最基础的 TCP Socket 开始,因为它是最常用、最可靠的,然后会介绍 UDP Socket,并附上完整的代码示例和最佳实践。

Java Socket发送数据时,如何确保数据完整到达?-图1
(图片来源网络,侵删)

核心概念

在开始编码前,先理解几个核心概念:

  1. Socket (套接字):网络通信的端点,可以把它想象成一个“插座”,你的程序通过这个插座来发送和接收数据。
  2. IP 地址:网络中设备的唯一标识,168.1.100www.google.com
  3. 端口号:设备上应用程序的唯一标识,一个 IP 地址可以同时运行多个网络服务(如 Web 服务器、邮件服务器),端口号用来区分它们,范围是 0-65535,0-1023 是知名端口,通常被系统服务占用。
  4. TCP (传输控制协议)
    • 面向连接:在发送数据前,必须先建立一个可靠的连接(三次握手)。
    • 可靠传输:通过确认、重传、排序等机制确保数据无差错、不丢失、不重复地到达。
    • 字节流:发送的数据像水流一样,没有明确的边界,接收方需要自己定义如何从流中分割出完整的数据包。
    • 适用于:对可靠性要求高的场景,如文件传输、网页浏览、邮件发送。
  5. UDP (用户数据报协议)
    • 无连接:发送数据前无需建立连接,直接发送数据包(Datagram)。
    • 不可靠传输:不保证数据包的顺序、是否到达或是否重复。
    • 数据报:数据以独立的包形式发送,每个包都有明确的边界。
    • 适用于:对实时性要求高、能容忍少量丢包的场景,如视频会议、在线游戏、DNS 查询。

基于 TCP 的 Socket 编程(最常用)

TCP 通信是客户端/服务器 模型。

流程图

+--------+      创建 ServerSocket       +--------+
| Client | -------------------------> | Server |
+--------+                          +--------+
      |                             | (监听端口)
      |       1. 连接请求          |
      | -------------------------> | (accept() 阻塞,等待连接)
      |                             |
      |       2. 获取 Socket      |
      | <------------------------- |
      |                             | 3. 获取输入/输出流
      |       3. 获取输入/输出流    |
      | <------------------------- |
      |                             |
      | 4. 通过 OutputStream 发送数据 |
      | -------------------------> |
      |                             | 5. 通过 InputStream 接收数据
      |       5. 通过 InputStream 接收数据 |
      | <------------------------- |
      |                             |
      ... (数据交换) ...
      |                             |
      | 6. 关闭 Socket 和流         |
      | <------------------------- |
      |                             | 7. 关闭 Socket 和流
      |                             |
+--------+                          +--------+
| Client | <------------------------- | Server |
+--------+                          +--------+

代码示例

服务器端

服务器需要先启动,并在一个指定的端口上等待客户端的连接。

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class TCPServer {
    public static void main(String[] args) {
        // 1. 创建一个 ServerSocket,并绑定到指定的端口 ( 8888)
        // 服务器在这个端口上监听客户端的连接请求
        try (ServerSocket serverSocket = new ServerSocket(8888)) {
            System.out.println("服务器已启动,等待客户端连接...");
            // 2. 调用 accept() 方法,它会阻塞当前线程,直到有客户端连接
            // accept() 返回一个 Socket 对象,代表与客户端建立的连接
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress().getHostAddress());
            // 3. 从 Socket 中获取输入流,用于接收客户端发送的数据
            InputStream inputStream = clientSocket.getInputStream();
            // 4. 从 Socket 中获取输出流,用于向客户端发送数据
            OutputStream outputStream = clientSocket.getOutputStream();
            // 5. 接收数据
            byte[] buffer = new byte[1024];
            int len;
            // read() 也会阻塞,直到有数据可读
            while ((len = inputStream.read(buffer)) != -1) {
                // 将接收到的字节数据转换为字符串并打印
                String message = new String(buffer, 0, len, "UTF-8");
                System.out.println("收到客户端消息: " + message);
                // 6. (可选) 向客户端发送响应
                String responseMessage = "服务器已收到你的消息: " + message;
                outputStream.write(responseMessage.getBytes("UTF-8"));
                outputStream.flush(); // 确保数据被立即发送
            }
            System.out.println("客户端已断开连接。");
            // 7. 关闭资源 (使用 try-with-resources 会自动关闭)
            // inputStream.close();
            // outputStream.close();
            // clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

客户端

客户端需要知道服务器的 IP 地址和端口号,然后主动发起连接。

Java Socket发送数据时,如何确保数据完整到达?-图2
(图片来源网络,侵删)
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
public class TCPClient {
    public static void main(String[] args) {
        // 服务器的 IP 地址和端口号
        String serverHost = "localhost"; // 如果在同一台机器上,用 "localhost"
        int serverPort = 8888;
        try (Socket socket = new Socket(serverHost, serverPort)) {
            System.out.println("已连接到服务器: " + socket.getRemoteSocketAddress());
            // 1. 从 Socket 中获取输出流,用于向服务器发送数据
            OutputStream outputStream = socket.getOutputStream();
            // 2. 从 Socket 中获取输入流,用于接收服务器的响应
            InputStream inputStream = socket.getInputStream();
            // 3. 发送数据
            String message = "你好,TCP 服务器!";
            System.out.println("向服务器发送消息: " + message);
            outputStream.write(message.getBytes("UTF-8"));
            outputStream.flush(); // 确保数据被立即发送
            // 4. 接收服务器的响应
            byte[] buffer = new byte[1024];
            int len;
            while ((len = inputStream.read(buffer)) != -1) {
                String response = new String(buffer, 0, len, "UTF-8");
                System.out.println("收到服务器响应: " + response);
                break; // 读取一次后退出
            }
            // 5. 关闭资源 (使用 try-with-resources 会自动关闭)
            // outputStream.close();
            // inputStream.close();
            // socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

如何运行

  1. 先运行 TCPServer.java,你会看到控制台打印 "服务器已启动,等待客户端连接..."。
  2. 然后运行 TCPClient.java
  3. 你会看到客户端成功连接,发送消息,并收到服务器的响应,服务器端也会打印出收到的消息。

基于 UDP 的 Socket 编程

UDP 是无连接的,通信双方地位对等,没有明确的客户端和服务器之分,但通常我们还是会有一方作为“接收方”,一方作为“发送方”。

流程图

+-----------+                       +-----------+
|  Sender   |                       |  Receiver |
+-----------+                       +-----------+
      |                                 |
      | 1. 创建 DatagramSocket         | 1. 创建 DatagramSocket
      |                                 | 2. 创建 DatagramPacket (用于接收)
      | 2. 创建 DatagramPacket (用于发送) | 3. 调用 receive() 阻塞等待
      | 3. 调用 send() 发送数据         |
      | ------------------------->      |
      |                                 | 4. 从 DatagramPacket 中获取数据
      |                                 |
      |                                 | 5. (可选) 创建新的 DatagramPacket 并回复
      |                                 | 6. 调用 send() 回复
      | <-------------------------      |
      |                                 |
      ... (数据交换) ...
      |                                 |

代码示例

接收方

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UDPReceiver {
    public static void main(String[] args) {
        int receivePort = 9999;
        try (DatagramSocket socket = new DatagramSocket(receivePort)) {
            System.out.println("UDP 接收方已启动,监听端口: " + receivePort);
            byte[] buffer = new byte[1024];
            // 创建一个 DatagramPacket 对象,用于接收数据
            // 它需要指定接收数据的字节数组和长度
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
            // receive() 方法会阻塞,直到收到一个数据包
            socket.receive(packet);
            // 从 packet 中获取实际接收到的数据长度
            String receivedMessage = new String(packet.getData(), 0, packet.getLength(), "UTF-8");
            System.out.println("收到消息: " + receivedMessage);
            System.out.println("来自: " + packet.getAddress().getHostAddress() + ":" + packet.getPort());
        } catch (SocketException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

发送方

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.UnknownHostException;
public class UDPSender {
    public static void main(String[] args) {
        String message = "你好,UDP 接收方!";
        String receiverHost = "localhost";
        int receiverPort = 9999;
        try (
            // 创建一个 DatagramSocket,系统会分配一个可用端口
            DatagramSocket socket = new DatagramSocket()
        ) {
            // 将要发送的消息转换为字节数组
            byte[] data = message.getBytes("UTF-8");
            // 接收方的 IP 地址
            InetAddress address = InetAddress.getByName(receiverHost);
            // 创建一个 DatagramPacket 对象,包含要发送的数据、长度、目标 IP 和端口
            DatagramPacket packet = new DatagramPacket(data, data.length, address, receiverPort);
            // 通过 send() 方法发送数据包
            socket.send(packet);
            System.out.println("消息已发送至 " + receiverHost + ":" + receiverPort);
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

如何运行

  1. 先运行 UDPReceiver.java,它会开始监听 9999 端口。
  2. 然后运行 UDPSender.java
  3. 接收方会收到消息并打印出来。

重要注意事项与最佳实践

字符编码

在网络传输中,字符串必须被转换为字节数组。强烈建议始终指定字符编码,如 UTF-8,以避免在不同系统上出现乱码问题。

// 正确
String msg = "你好";
byte[] bytes = msg.getBytes("UTF-8");
String msg2 = new String(bytes, "UTF-8");
// 错误 (使用平台默认编码,可能导致跨平台问题)
byte[] bytes = msg.getBytes();
String msg2 = new String(bytes);

资源管理

Socket 和相关的输入/输出流都是需要系统资源的,使用完毕后必须关闭,推荐使用 try-with-resources 语句,它可以自动关闭实现了 AutoCloseable 接口的对象。

// 推荐写法
try (Socket socket = new Socket(...);
     OutputStream os = socket.getOutputStream();
     InputStream is = socket.getInputStream()) {
    // ... 使用 socket, os, is 进行操作 ...
} catch (IOException e) {
    // 处理异常
} // try-with-resources 会自动调用 close() 方法

TCP 的流式数据问题

TCP 是字节流,没有消息边界,如果客户端连续发送两条消息,服务器端可能会在一次 read() 中读到两条消息粘在一起,或者一条消息被分成多次 read()

解决方案:自定义协议 在发送的实际数据前,加上数据的长度。 [数据长度(4字节)][实际数据]

客户端发送时:

String msg1 = "Hello";
String msg2 = "World";
// 发送第一条消息
byte[] data1 = msg1.getBytes("UTF-8");
byte[] lenBytes1 = ByteBuffer.allocate(4).putInt(data1.length).array();
outputStream.write(lenBytes1);
outputStream.write(data1);
// 发送第二条消息
byte[] data2 = msg2.getBytes("UTF-8");
byte[] lenBytes2 = ByteBuffer.allocate(4).putInt(data2.length).array();
outputStream.write(lenBytes2);
outputStream.write(data2);

服务器接收时:

byte[] lenBytes = new byte[4];
inputStream.read(lenBytes); // 先读长度
int length = ByteBuffer.wrap(lenBytes).getInt();
byte[] dataBytes = new byte[length];
int totalRead = 0;
while (totalRead < length) {
    int read = inputStream.read(dataBytes, totalRead, length - totalRead);
    totalRead += read;
}
String message = new String(dataBytes, "UTF-8");

处理阻塞

SocketInputStream.read()ServerSocket.accept() 等方法都是阻塞的,在实际应用中,通常会用多线程(一个连接一个线程)或更高级的 I/O 模型(如 NIO,即 java.nio.channels 包)来处理并发连接,以避免主线程被阻塞。

异常处理

网络编程充满了不确定性,如连接中断、网络延迟、目标主机不可达等,所有网络操作都应该被 try-catch 块包围,并妥善处理 IOException 及其子类。


特性 TCP (Socket/ServerSocket) UDP (DatagramSocket/DatagramPacket)
连接性 面向连接 无连接
可靠性 高(保证顺序、不丢失) 低(不保证顺序、可能丢失)
速度 较慢 较快
模型 客户端/服务器 对等(但通常有接收/发送方之分)
数据形式 字节流 数据报
适用场景 文件传输、网页浏览、邮件 视频会议、在线游戏、DNS
Java API Socket, ServerSocket, InputStream, OutputStream DatagramSocket, DatagramPacket

希望这份详细的指南能帮助你掌握 Java Socket 编程!从 TCP 开始,理解其阻塞特性和流式数据的处理是关键。

分享:
扫描分享到社交APP
上一篇
下一篇