杰瑞科技汇

java socket 二进制

  1. 数据表示:在 Java 中,所有数据都是对象,二进制数据通常使用 byte 数组 (byte[]) 来表示。
  2. :Socket 通信基于 I/O 流,发送数据通过 OutputStream,接收数据通过 InputStream
  3. 数据转换:如何将 Java 的基本数据类型(如 int, double, long)转换为字节数组,以及如何反向转换回来,这涉及到 字节序 的问题。
  4. TCP 的粘包/拆包问题:TCP 是一个流式协议,它不会保留你发送消息的边界,发送方多次 write 的数据可能会在接收方 read 时一次性被读取,这就是“粘包”;反之,一次 write 的数据也可能被拆分成多次 read,这就是“拆包”,我们需要设计协议来解决这些问题。

下面我们分步讲解,并提供一个完整的、可运行的示例。


基础:发送和接收 byte[]

这是最基本的方式,直接传输原始的字节数组。

客户端发送 byte[]

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class BasicClient {
    public static void main(String[] args) {
        String host = "127.0.0.1";
        int port = 8080;
        try (Socket socket = new Socket(host, port);
             OutputStream out = socket.getOutputStream()) {
            // 1. 准备要发送的二进制数据
            String message = "Hello, Socket!";
            byte[] data = message.getBytes(StandardCharsets.UTF_8); // 使用UTF-8编码
            System.out.println("客户端准备发送: " + message);
            System.out.println("发送的字节数: " + data.length);
            // 2. 通过 OutputStream 发送数据
            out.write(data);
            System.out.println("客户端发送完成。");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服务端接收 byte[]

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class BasicServer {
    public static void main(String[] args) {
        int port = 8080;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器启动,等待客户端连接...");
            // 1. 接受客户端连接
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress());
            try (InputStream in = clientSocket.getInputStream()) {
                // 2. 创建一个缓冲区来接收数据
                // 注意:这里不知道数据有多大,所以先定义一个合理的缓冲区大小
                byte[] buffer = new byte[1024];
                int bytesRead = in.read(buffer); // 阻塞,直到有数据可读
                if (bytesRead > 0) {
                    // 3. 只读取实际接收到的数据部分
                    byte[] receivedData = new byte[bytesRead];
                    System.arraycopy(buffer, 0, receivedData, 0, bytesRead);
                    System.out.println("服务器收到字节数: " + receivedData.length);
                    String receivedMessage = new String(receivedData, StandardCharsets.UTF_8);
                    System.out.println("服务器收到内容: " + receivedMessage);
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

问题:上面的服务端代码有一个严重缺陷,如果客户端发送的数据超过 1024 字节,in.read(buffer) 只会读取前 1024 字节,剩余的数据会留在流中,导致数据丢失。


进阶:发送和接收 Java 基本数据类型

在实际应用中,我们通常需要传输 int, double, long 等类型,这需要将它们转换为字节数组,这个过程称为 序列化,反向称为 反序列化

关键点:字节序

计算机在处理多字节数据(如 int 是 4 字节)时,有两种方式存储字节的顺序:

  • 大端序:高位字节在内存的低地址,低位字节在高地址,网络传输标准(如 TCP/IP)规定使用大端序。
  • 小端序:低位字节在内存的低地址,高位字节在高地址,大多数现代 CPU(如 x86)使用小端序。

为了保证跨平台兼容性,我们必须手动处理字节序,通常统一使用 大端序

工具类:DataXxx

Java 提供了 DataOutputStreamDataInputStream 来方便地进行基本数据类型的转换,它们内部已经处理了字节序问题。

客户端发送多种数据类型

import java.io.DataOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class AdvancedClient {
    public static void main(String[] args) {
        String host = "127.0.0.1";
        int port = 8080;
        try (Socket socket = new Socket(host, port);
             // 使用 DataOutputStream 来写入基本数据类型
             DataOutputStream out = new DataOutputStream(socket.getOutputStream())) {
            // 1. 发送一个 int
            int intValue = 123456789;
            out.writeInt(intValue);
            System.out.println("客户端发送 int: " + intValue);
            // 2. 发送一个 double
            double doubleValue = 3.1415926;
            out.writeDouble(doubleValue);
            System.out.println("客户端发送 double: " + doubleValue);
            // 3. 发送一个 String
            String message = "你好,世界!";
            byte[] stringBytes = message.getBytes(StandardCharsets.UTF_8);
            out.writeInt(stringBytes.length); // 先发送字符串的长度
            out.write(stringBytes);          // 再发送字符串的内容
            System.out.println("客户端发送 String: " + message);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

服务端接收多种数据类型

import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class AdvancedServer {
    public static void main(String[] args) {
        int port = 8080;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器启动,等待客户端连接...");
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress());
            try (InputStream in = clientSocket.getInputStream();
                 // 使用 DataInputStream 来读取基本数据类型
                 DataInputStream dataIn = new DataInputStream(in)) {
                // 1. 读取一个 int
                int receivedInt = dataIn.readInt();
                System.out.println("服务器收到 int: " + receivedInt);
                // 2. 读取一个 double
                double receivedDouble = dataIn.readDouble();
                System.out.println("服务器收到 double: " + receivedDouble);
                // 3. 读取一个 String
                int stringLength = dataIn.readInt(); // 先读取长度
                byte[] stringBytes = new byte[stringLength];
                dataIn.readFully(stringBytes);      // 读取指定长度的字节
                String receivedString = new String(stringBytes, StandardCharsets.UTF_8);
                System.out.println("服务器收到 String: " + receivedString);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

注意

  • out.writeInt()dataIn.readInt() 是配套的。
  • 对于 String,我们采用 长度前缀 的方式来分隔,这是解决 TCP 粘包问题的最简单有效的方法之一。

解决 TCP 粘包/拆包问题

在上面的 AdvancedClient/Server 示例中,我们通过发送数据长度前缀来解决了粘包问题,这是一个非常好的实践。

更通用的方法是设计一个简单的 应用层协议

协议设计示例: 每个数据包由两部分组成:

  1. 包头:固定 4 字节,表示整个数据包的长度(包括包头本身)。
  2. 包体:变长数据。

这样,接收方总是先读取 4 个字节,得到包的总长度 N,然后再从流中读取 N - 4 个字节,就得到了一个完整的包。

实现一个简单的协议客户端

import java.io.DataOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class ProtocolClient {
    public static void main(String[] args) {
        String host = "127.0.0.1";
        int port = 8080;
        try (Socket socket = new Socket(host, port);
             DataOutputStream out = new DataOutputStream(socket.getOutputStream())) {
            // 发送第一个包
            String message1 = "这是第一个消息";
            byte[] body1 = message1.getBytes(StandardCharsets.UTF_8);
            int totalLength1 = 4 + body1.length; // 包头(4) + 包体
            out.writeInt(totalLength1);
            out.write(body1);
            System.out.println("客户端发送包1: 长度=" + totalLength1 + ", 内容=" + message1);
            // 发送第二个包
            String message2 = "这是第二个稍微长一点的消息";
            byte[] body2 = message2.getBytes(StandardCharsets.UTF_8);
            int totalLength2 = 4 + body2.length; // 包头(4) + 包体
            out.writeInt(totalLength2);
            out.write(body2);
            System.out.println("客户端发送包2: 长度=" + totalLength2 + ", 内容=" + message2);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

实现一个简单的协议服务端

import java.io.DataInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
public class ProtocolServer {
    public static void main(String[] args) {
        int port = 8080;
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("服务器启动,等待客户端连接...");
            Socket clientSocket = serverSocket.accept();
            System.out.println("客户端已连接: " + clientSocket.getInetAddress());
            try (DataInputStream in = new DataInputStream(clientSocket.getInputStream())) {
                while (true) {
                    try {
                        // 1. 读取包头(4字节),获取整个数据包的长度
                        int packetLength = in.readInt();
                        System.out.println("服务器准备接收一个数据包,总长度: " + packetLength);
                        // 2. 读取包体
                        byte[] body = new byte[packetLength - 4]; // 减去包头长度
                        in.readFully(body); // 读取指定长度的字节,保证读取完整
                        // 3. 处理包体数据
                        String message = new String(body, java.nio.charset.StandardCharsets.UTF_8);
                        System.out.println("服务器收到包内容: " + message);
                    } catch (IOException e) {
                        // readFully 抛出 EOFException 表示连接已关闭
                        System.out.println("客户端关闭了连接。");
                        break;
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

更高级的选择:ByteBuffer

对于更复杂的二进制协议处理,Java NIO 的 ByteBuffer 是一个非常强大的工具,它不仅可以处理字节序,还提供了更丰富的 API。

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
// 示例:使用 ByteBuffer 将 int 和 String 打包
public class ByteBufferExample {
    public static void main(String[] args) {
        // 1. 创建一个 ByteBuffer,并指定字节序为大端
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        buffer.order(ByteOrder.BIG_ENDIAN);
        // 2. 写入数据
        int id = 101;
        String name = "Test User";
        byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
        buffer.putInt(id);
        buffer.putInt(nameBytes.length);
        buffer.put(nameBytes);
        // 3. 准备发送(切换到读模式)
        buffer.flip();
        // 4. buffer.array() 就包含了可以发送的二进制数据
        byte[] dataToSend = new byte[buffer.remaining()];
        buffer.get(dataToSend);
        System.out.println("打包后的字节数组长度: " + dataToSend.length);
    }
}

使用 ByteBuffer 可以让你的协议打包和解包逻辑更清晰、更健壮。


总结与最佳实践

  1. 使用 byte[] 作为基本数据单元:所有二进制数据最终都以 byte[] 形式在 Socket 上传输。
  2. 明确字节序:跨平台通信时,统一使用 大端序DataOutputStreamByteBuffer.order(ByteOrder.BIG_ENDIAN) 都能帮你做到这一点。
  3. 解决粘包/拆包是关键:这是 Socket 编程的难点和重点,最常用的方法是 长度前缀协议
    • 简单数据:在发送 Stringbyte[] 前,先发送其 length
    • 复杂数据包:设计包头,包含整个数据包的长度、消息类型、版本号等信息。
  4. DataOutputStream / DataInputStream:对于基本数据类型(int, double 等),这是最简单直接的工具。
  5. ByteBuffer:对于需要精细控制二进制格式的场景(如自定义协议、文件传输等),ByteBuffer 是更现代、更强大的选择。
  6. 资源管理:始终使用 try-with-resources 语句来确保 Socket, InputStream, OutputStream 等资源被正确关闭。

希望这个详细的讲解能帮助你掌握 Java Socket 的二进制通信!

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