杰瑞科技汇

java socket 数据包

在 TCP Socket 编程中,我们通常不直接操作“数据包”(Packet)。

java socket 数据包-图1
(图片来源网络,侵删)
  • 数据包 是一个网络层的概念,它包含了源/目的 IP 地址、端口号等完整的路由信息,是 IP 协议(如 UDP 数据报)处理的基本单位。
  • TCP Socket 工作在传输层,它提供的是面向连接的、可靠的、基于字节流的通信,它不关心你发送的数据的“边界”,只负责将你写入的字节流按顺序、完整地发送到对方,并接收对方发来的字节流。

在 Java TCP Socket 编程中,我们处理的是数据流,而不是数据包,但理解 TCP 数据段的结构和 Java 如何将你的数据打包成 TCP 段,对于编写健壮的 Socket 程序至关重要。

下面我将从以下几个方面展开:

  1. Java Socket 的核心类与字节流
  2. TCP 如何将你的数据打包成“数据段”
  3. 如何发送和接收结构化的数据(解决粘包/拆包问题)
  4. 一个完整的、可运行的 TCP 通信示例
  5. UDP 数据报编程简介(真正的“数据包”)

Java Socket 的核心类与字节流

Java 使用 java.net 包中的类来处理网络通信,核心类有:

  • Socket: 代表一个客户端套接字,它用于发起连接。
  • ServerSocket: 代表一个服务器端套接字,它用于监听并接受客户端的连接。
  • InputStream / OutputStream: 通过 Socket 对象获取的输入/输出流,它们是字节流,是数据传输的通道。

关键点:getInputStream()getOutputStream() 返回的是字节流。

java socket 数据包-图2
(图片来源网络,侵删)

这意味着你写入 OutputStream 的任何数据(无论是字符串、图片还是对象)最终都会被转换成字节序列,同样,从 InputStream 读取的也是一连串的字节,你需要知道如何正确地解析它们。


TCP 如何将你的数据打包成“数据段”

当你通过 socket.getOutputStream().write(byte[] data) 发送数据时,TCP 协议栈会做以下事情:

  1. 分段:TCP 不会立刻把所有数据都发送出去,它会将你的字节流分割成一个或多个TCP 段,分段的大小受多种因素影响,如:

    • MSS (Maximum Segment Size):网络路径中允许的最大 TCP 段大小,通常与以太网的 MTU (1500字节) 有关。
    • 发送方的拥塞窗口大小:这是 TCP 流量控制的核心,防止发送方数据过快导致网络拥塞。
    • 接收方的接收窗口大小:接收方通过 ACK 报文告诉发送方自己还能接收多少数据。
  2. 添加 TCP 头部:每个 TCP 段都会被加上一个 TCP 头部(通常是 20 字节),包含了:

    java socket 数据包-图3
    (图片来源网络,侵删)
    • 源端口号和目的端口号
    • 序列号:确保数据按顺序重组。
    • 确认号:用于确认已收到的数据。
    • 其他控制位(如 SYN, ACK, FIN 等)和校验和。
  3. 封装成 IP 数据包:TCP 段会被进一步封装成一个 IP 数据包,加上 IP 头部(20 字节),包含源/目的 IP 地址。

你调用一次 write(),底层可能会产生 0 个、1 个或多个 TCP 段(即 IP 数据包),这就是为什么你不能假设 write() 一次发送的数据,对方就能通过 read() 一次完整地读出来。


如何发送和接收结构化的数据(解决粘包/拆包问题)

这是 TCP Socket 编程中最核心、最常见的问题,由于 TCP 是字节流协议,接收方 read() 时可能会遇到以下情况:

  • 粘包:发送方连续发送了两个数据包,但接收方在一次 read() 中读取到了两个包的数据。
  • 拆包:发送方发送的一个大数据包,被 TCP 拆分成多个段,接收方需要多次 read() 才能将其完整读取。

解决方案:定义一个应用层协议,让接收方知道每个数据包的边界,常见的方法有:

固定长度

  • 发送:每个数据包都固定为 N 个字节,如果数据不足 N 字节,用空格或特定字符填充。
  • 接收:每次循环读取 N 个字节。
  • 缺点:浪费空间,不灵活。

特殊分隔符

  • 发送:在每个数据包末尾加上一个特殊且不会在数据中出现的分隔符(如 \r\n, \0)。
  • 接收:循环读取字节,直到读到分隔符,将之前的数据作为一个完整包处理。
  • 缺点:如果数据中本身包含分隔符,需要进行转义,增加了复杂性。

长度前缀(最常用、最推荐)

  • 发送
    1. 先计算要发送数据的字节数
    2. 将这个字节数(用 int 类型,占 4 字节)作为头部发送出去。
    3. 再发送真正的数据内容。
  • 接收
    1. 先读取 4 个字节,解析出数据内容的长度 dataLength
    2. 然后循环读取,直到读取到 dataLength 个字节,这就是一个完整的数据包。
    3. 重复以上步骤。

Java 实现长度前缀示例:

假设我们要发送一个字符串。

发送端代码片段:

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class Sender {
    public static void main(String[] args) throws Exception {
        try (Socket socket = new Socket("localhost", 8888);
             OutputStream os = socket.getOutputStream();
             DataOutputStream dos = new DataOutputStream(os)) {
            String message1 = "Hello, this is the first message.";
            String message2 = "This is a slightly longer second message.";
            // --- 发送第一个消息 ---
            byte[] data1 = message1.getBytes(StandardCharsets.UTF_8);
            // 1. 先发送数据长度 (int, 4 bytes)
            dos.writeInt(data1.length);
            // 2. 再发送数据内容
            dos.write(data1);
            // --- 发送第二个消息 ---
            byte[] data2 = message2.getBytes(StandardCharsets.UTF_8);
            dos.writeInt(data2.length);
            dos.write(data2);
        }
    }
}

接收端代码片段:

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class Receiver {
    public static void main(String[] args) throws Exception {
        try (ServerSocket serverSocket = new ServerSocket(8888)) {
            System.out.println("Server is listening on port 8888...");
            try (Socket socket = serverSocket.accept();
                 InputStream is = socket.getInputStream();
                 DataInputStream dis = new DataInputStream(is)) {
                while (true) {
                    try {
                        // 1. 先读取数据长度 (int, 4 bytes)
                        int length = dis.readInt();
                        if (length < 0) {
                            // 可能是客户端正常关闭,处理错误等
                            break;
                        }
                        // 2. 根据长度创建一个字节数组
                        byte[] receivedData = new byte[length];
                        // 3. 循环读取,直到读取满 length 个字节
                        // dis.read() 一次可能读不完,需要循环
                        int totalRead = 0;
                        while (totalRead < length) {
                            int read = dis.read(receivedData, totalRead, length - totalRead);
                            if (read == -1) {
                                // 连接已关闭
                                break;
                            }
                            totalRead += read;
                        }
                        if (totalRead == length) {
                            // 成功读取一个完整的数据包
                            String message = new String(receivedData, StandardCharsets.UTF_8);
                            System.out.println("Received message: " + message);
                        } else {
                            System.out.println("Connection closed before receiving full packet.");
                            break;
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                        break;
                    }
                }
            }
        }
    }
}

一个完整的、可运行的 TCP 通信示例

这个例子将服务器和客户端放在一个文件中,并实现了基于长度前缀的字符串通信。

TCPServerClient.java

import java.io.*;
import java.net.*;
public class TCPServerClient {
    // 服务器端
    static class Server {
        public static void start(int port) {
            try (ServerSocket serverSocket = new ServerSocket(port)) {
                System.out.println("Server is listening on port " + port);
                Socket socket = serverSocket.accept();
                System.out.println("Client connected: " + socket.getInetAddress());
                try (InputStream is = socket.getInputStream();
                     DataInputStream dis = new DataInputStream(is)) {
                    while (true) {
                        try {
                            // 1. 读取消息长度
                            int length = dis.readInt();
                            if (length == -1) {
                                System.out.println("Client is closing the connection...");
                                break;
                            }
                            // 2. 根据长度读取消息内容
                            byte[] data = new byte[length];
                            int bytesRead = 0;
                            while (bytesRead < length) {
                                int read = dis.read(data, bytesRead, length - bytesRead);
                                if (read == -1) {
                                    throw new IOException("Connection lost while reading data.");
                                }
                                bytesRead += read;
                            }
                            String message = new String(data, "UTF-8");
                            System.out.println("Received from client: " + message);
                            // 简单回显
                            sendResponse(socket, "Server echoes: " + message);
                        } catch (EOFException e) {
                            System.out.println("Client closed the connection.");
                            break;
                        }
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    // 客户端
    static class Client {
        public static void connect(String host, int port) {
            try (Socket socket = new Socket(host, port);
                 OutputStream os = socket.getOutputStream();
                 DataOutputStream dos = new DataOutputStream(os);
                 BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in))) {
                System.out.println("Connected to server.");
                String userInput;
                while (true) {
                    System.out.print("Enter message (or 'exit' to quit): ");
                    userInput = consoleReader.readLine();
                    if ("exit".equalsIgnoreCase(userInput)) {
                        // 发送一个特殊信号或直接关闭
                        break;
                    }
                    byte[] data = userInput.getBytes("UTF-8");
                    // 1. 发送消息长度
                    dos.writeInt(data.length);
                    // 2. 发送消息内容
                    dos.write(data);
                    dos.flush(); // 确保数据被发送
                    // 等待服务器响应
                    receiveResponse(socket);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    // 辅助方法:发送响应
    private static void sendResponse(Socket socket, String response) throws IOException {
        try (OutputStream os = socket.getOutputStream();
             DataOutputStream dos = new DataOutputStream(os)) {
            byte[] data = response.getBytes("UTF-8");
            dos.writeInt(data.length);
            dos.write(data);
            dos.flush();
        }
    }
    // 辅助方法:接收响应
    private static void receiveResponse(Socket socket) throws IOException {
        try (InputStream is = socket.getInputStream();
             DataInputStream dis = new DataInputStream(is)) {
            int length = dis.readInt();
            byte[] data = new byte[length];
            dis.readFully(data); // readFully 会一直读取直到填满数组
            String message = new String(data, "UTF-8");
            System.out.println("Response from server: " + message);
        }
    }
    public static void main(String[] args) {
        // 在新线程中启动服务器
        new Thread(() -> Server.start(8888)).start();
        // 等待服务器启动
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 在主线程中启动客户端
        Client.connect("localhost", 8888);
    }
}

UDP 数据报编程简介(真正的“数据包”)

如果你需要的是真正的“数据包”模型(无连接、不可靠、有边界),那么应该使用 UDP。

Java 中使用 DatagramSocketDatagramPacket

  • DatagramSocket: 发送和接收数据报的套接字。
  • DatagramPacket: 数据包的容器,包含了要发送的数据、目标地址和端口,或接收到的数据和源地址。

UDP 示例代码片段:

发送方:

import java.net.*;
public class UDPSender {
    public static void main(String[] args) throws Exception {
        DatagramSocket socket = new DatagramSocket();
        String message = "Hello from UDP!";
        byte[] data = message.getBytes();
        // 创建数据包,指定目标地址和端口
        InetAddress address = InetAddress.getByName("localhost");
        DatagramPacket packet = new DatagramPacket(data, data.length, address, 9876);
        // 发送数据包
        socket.send(packet);
        System.out.println("Sent: " + message);
        socket.close();
    }
}

接收方:

import java.net.*;
public class UDPReceiver {
    public static void main(String[] args) throws Exception {
        DatagramSocket socket = new DatagramSocket(9876);
        byte[] buffer = new byte[1024];
        System.out.println("UDP Server is listening on port 9876...");
        while (true) {
            // 创建一个空的数据包用于接收
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
            // 接收数据包,此方法会阻塞直到收到数据
            socket.receive(packet);
            // 从数据包中提取数据
            String receivedMessage = new String(packet.getData(), 0, packet.getLength());
            System.out.println("Received from " + packet.getAddress() + ": " + receivedMessage);
        }
        // socket.close(); // 通常不会关闭
    }
}
特性 TCP (Socket) UDP (DatagramPacket)
连接 面向连接 无连接
可靠性 可靠(保证顺序、无丢失) 不可靠(可能丢失、乱序)
数据单位 字节流 数据报(数据包)
边界 无边界 有明确边界
效率 较低(有连接、确认、重传) 较高(无连接开销)
应用场景 文件传输、网页浏览、邮件等要求可靠性的场景 视频会议、在线游戏、DNS查询等要求速度、能容忍少量丢包的场景

对于 Java Socket 数据包,核心思想是:在 TCP 字节流的模型上,通过定义应用层协议(如长度前缀)来模拟“数据包”的边界,从而实现可靠的结构化数据传输。

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