本篇文章主要介绍了TCP协议中的粘包问题,以及如何正确处理TCP消息边界。
TCP协议的一些细节
-
TCP是面向字节流的协议
-
网络传输过程中,多个TCP数据包可能被合并成一个数据包进行传输。
-
TCP拥塞控制算法**
Nagle
**- 如果包长度达到
MSS
,则允许发送; - 如果该包含有
FIN
,则允许发送; - 设置了
TCP_NODELAY
选项,则允许发送; - 未设置
TCP_CORK
选项时,若所有发出去的小数据包(包长度小于MSS
)均被确认,则允许发送; - 上述条件都未满足,但发生了超时(一般为
200ms
),则立即发送。
- 如果包长度达到
-
发送方会将多个数据包放入一个TCP数据包中发送
-
接收方会先把收到的数据放在系统接收缓冲区,用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据。
粘包问题的实际含义
从TCP的这些细节我们可以看出,由于TCP是面向字节流的协议,不论是在发送端,传输链路,还是在接收端,多个TCP数据包,都有可能被合并成一个。这种特点会给我们造成一些困扰,比如我们发送了两条消息“hello”和“world”,预期应该是这样的
但是有可能会变成这样子
到这里我们可以看出,所谓的粘包问题,实际上问的是:TCP的上层应用如何正确处理消息边界。
具体来讲,不是所有的粘包现象都需要处理,若传输的数据为不带结构的连续流数据(如文件传输),则不必把粘连的包分开(简称分包)。但在实际工程应用中,传输的数据一般为带结构的数据,这时就需要做分包处理。
如何正确处理TCP消息边界
为了避免TCP粘包,可以采用以下方法:
- 使用消息头和消息体:发送方在每个数据包的头部添加一个消息头,消息头中包含了该数据包的长度信息,接收方在接收数据时,先读取消息头,然后根据消息头中的长度信息来读取消息体,从而避免了粘包的问题。
- 使用特殊分隔符:发送方在每个数据包的末尾添加一个特殊的分隔符,接收方在接收数据时,通过该分隔符来区分每个数据包的边界。
- 消息定长:发送方在发送数据时,按照固定的长度进行分割,接收方按照固定的长度进行接收和解析,从而避免了粘包的问题。
使用消息头和消息体
好的,以下是一个使用Java代码实现基于消息头和消息体的TCP粘包示例:
发送方代码
import java.ioimport java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.ByteBuffer;
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8888);
System.out.println("Connected to server.");
OutputStream outputStream = socket.getOutputStream();
// 发送第一个消息
String message1 = "Hello";
byte[] message1Bytes = message1.getBytes();
int length1 = message1Bytes.length;
byte[] header1 = ByteBuffer.allocate(4).putInt(length1).array();
byte[] packet1 = new byte[header1.length + message1Bytes.length];
System.arraycopy(header1, 0, packet1, 0, header1.length);
System.arraycopy(message1Bytes, 0, packet1, header1.length, message1Bytes.length);
outputStream.write(packet1);
// 发送第二个消息
String message2 = "World";
byte[] message2Bytes = message2.getBytes();
int length2 = message2Bytes.length;
byte[] header2 = ByteBuffer.allocate(4).putInt(length2).array();
byte[] packet2 = new byte[header2.length + message2Bytes.length];
System.arraycopy(header2, 0, packet2, 0, header2.length);
System.arraycopy(message2Bytes, 0, packet2, header2.length, message2Bytes.length);
outputStream.write(packet2);
socket.close();
System.out.println("Connection closed.");
}
}
接收方代码
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.ByteBuffer;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("Server started at port 8888.");
while (true) {
Socket socket = serverSocket.accept();
System.out.println("Accepted a new client.");
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
while (true) {
// 读取消息头
byte[] header = new byte[4];
inputStream.read(header);
// 解析消息长度
int length = ByteBuffer.wrap(header).getInt();
// 读取消息体
byte[] message = new byte[length];
inputStream.read(message);
// 将字节数组转换为字符串,并打印
String str = new String(message);
System.out.println("Received message: " + str);
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
}
在上面的示例中,发送数据时,也先将消息转换为字节数组,并计算出消息的长度信息,然后在每个数据包的头部添加一个四字节的消息头,将消息头和消息体合并为一个数据包,然后发送给服务端。
在接收数据时,先读取四个字节的消息头,然后根据消息头中的长度信息来读取相应长度的消息体。
使用特殊分隔符
发送方代码
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8888);
System.out.println("Connected to server.");
OutputStream outputStream = socket.getOutputStream();
// 发送第一个消息
String message1 = "Hello#";
byte[] message1Bytes = message1.getBytes("UTF-8");
outputStream.write(message1Bytes);
// 发送第二个消息
String message2 = "World#";
byte[] message2Bytes = message2.getBytes("UTF-8");
outputStream.write(message2Bytes);
socket.close();
System.out.println("Connection closed.");
}
}
接收方代码
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("Server started at port 8888.");
while (true) {
Socket socket = serverSocket.accept();
System.out.println("Accepted a new client.");
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(buffer)) != -1) {
// 将字节数据转换为字符串
String data = new String(buffer, 0, len, "UTF-8");
// 拼接字符串
sb.append(data);
// 判断是否收到了一个完整的消息
String message = sb.toString();
int index;
while ((index = message.indexOf("#")) != -1) {
String packet = message.substring(0, index);
System.out.println("Received message: " + packet);
sb.delete(0, index + 1);
message = message.substring(index + 1);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
}
服务端在接收数据时,先将字节数据转换为字符串,然后将字符串拼接到一个可变字符串中,然后使用特殊分隔符“#”来判断每个数据包的边界,如果收到一个完整的数据包,则将该数据包解析为一个消息并打印出来。如果收到的数据包是不完整的,则保留到下一次读取时继续拼接。
如果特殊分隔符不能与消息中的数据重复,否则就会出现解析错误的问题,可以通过以下两种方式解决
- 转义特殊字符:在消息中出现特殊分隔符时,使用转义字符对其进行转义,如使用“\”对“#”进行转义,这样就可以避免特殊分隔符与消息中的数据重复的问题。
- 使用多个特殊分隔符:在消息中使用两个或多个特殊分隔符来表示一个分隔符,例如使用“##”来表示一个“#”,这样就可以避免特殊分隔符与消息中的数据重复的问题。
使用“转义特殊字符”处理分隔符与消息中数据重复的问题
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("Server started at port 8888.");
while (true) {
Socket socket = serverSocket.accept();
System.out.println("Accepted a new client.");
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(buffer)) != -1) {
// 将字节数据转换为字符串
String data = new String(buffer, 0, len, "UTF-8");
// 拼接字符串
sb.append(data);
// 判断是否收到了一个完整的消息
String message = sb.toString();
int index;
while ((index = message.indexOf("#")) != -1) {
// 判断特殊分隔符是否被转义
boolean isEscaped = false;
if (index > 0 && message.charAt(index - 1) == '\\') {
isEscaped = true;
}
if (isEscaped) {
sb.delete(index - 1, index + 1);
message = message.substring(index + 1);
} else {
String packet = message.substring(0, index);
System.out.println("Received message: " + packet);
sb.delete(0, index + 1);
message = message.substring(index + 1);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
}
}
固定消息长度
发送方
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 8888);
System.out.println("Connected to server.");
OutputStream outputStream = socket.getOutputStream();
// 发送第一个消息
String message1 = "Hello";
byte[] message1Bytes = message1.getBytes("UTF-8");
if (message1Bytes.length < 1024) {
byte[] padding = new byte[1024 - message1Bytes.length];
for (int i = 0; i < padding.length; i++) {
padding[i] = '#'; // 使用特殊字符“#”来填充消息
}
outputStream.write(message1Bytes);
outputStream.write(padding);
}
// 发送第二个消息
String message2 = "World";
byte[] message2Bytes = message2.getBytes("UTF-8");
if (message2Bytes.length < 1024) {
byte[] padding = new byte[1024 - message2Bytes.length];
for (int i =0; i < padding.length; i++) {
padding[i] = '#'; // 使用特殊字符“#”来填充消息
}
outputStream.write(message2Bytes);
outputStream.write(padding);
}
socket.close();
System.out.println("Connection closed.");
}
}
接受方
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8888);
System.out.println("Server started at port 8888.");
while (true) {
Socket socket = serverSocket.accept();
System.out.println("Accepted a new client.");
new Thread(() -> {
try {
InputStream inputStream = socket.getInputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
// 解析固定长度的数据包
String packet = new String(buffer, 0, len, "UTF-8");
System.out.println("Received message: " + packet);
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
}
}