- 数据表示:在 Java 中,所有数据都是对象,二进制数据通常使用
byte数组 (byte[]) 来表示。 - 流:Socket 通信基于 I/O 流,发送数据通过
OutputStream,接收数据通过InputStream。 - 数据转换:如何将 Java 的基本数据类型(如
int,double,long)转换为字节数组,以及如何反向转换回来,这涉及到 字节序 的问题。 - 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 提供了 DataOutputStream 和 DataInputStream 来方便地进行基本数据类型的转换,它们内部已经处理了字节序问题。
客户端发送多种数据类型
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 示例中,我们通过发送数据长度前缀来解决了粘包问题,这是一个非常好的实践。
更通用的方法是设计一个简单的 应用层协议。
协议设计示例: 每个数据包由两部分组成:
- 包头:固定 4 字节,表示整个数据包的长度(包括包头本身)。
- 包体:变长数据。
这样,接收方总是先读取 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 可以让你的协议打包和解包逻辑更清晰、更健壮。
总结与最佳实践
- 使用
byte[]作为基本数据单元:所有二进制数据最终都以byte[]形式在 Socket 上传输。 - 明确字节序:跨平台通信时,统一使用 大端序。
DataOutputStream和ByteBuffer.order(ByteOrder.BIG_ENDIAN)都能帮你做到这一点。 - 解决粘包/拆包是关键:这是 Socket 编程的难点和重点,最常用的方法是 长度前缀协议。
- 简单数据:在发送
String或byte[]前,先发送其length。 - 复杂数据包:设计包头,包含整个数据包的长度、消息类型、版本号等信息。
- 简单数据:在发送
DataOutputStream/DataInputStream:对于基本数据类型(int,double等),这是最简单直接的工具。ByteBuffer:对于需要精细控制二进制格式的场景(如自定义协议、文件传输等),ByteBuffer是更现代、更强大的选择。- 资源管理:始终使用
try-with-resources语句来确保Socket,InputStream,OutputStream等资源被正确关闭。
希望这个详细的讲解能帮助你掌握 Java Socket 的二进制通信!
