tcp粘包和拆包的原因及处理⽅案
随着智能硬件越来越流⾏,很多后端开发⼈员都有可能接触到socket编程。⽽很多情况下,服务器与端上需要保证数据的有序,稳定到达,⾃然⽽然就会选择基于tcp/ip协议的socekt开发。开发过程中,经常会遇到tcp粘包,拆包的问题,本⽂将从产⽣原因,和解决⽅案以及workerman是如何处理粘包拆包问题的,这⼏个层⾯来说明这个问题。
什么是粘包拆包
对于什么是粘包、拆包问题,我想先举两个简单的应⽤场景:
1. 客户端和服务器建⽴⼀个连接,客户端发送⼀条消息,客户端关闭与服务端的连接。
2. 客户端和服务器简历⼀个连接,客户端连续发送两条消息,客户端关闭与服务端的连接。
对于第⼀种情况,服务端的处理流程可以是这样的:当客户端与服务端的连接建⽴成功之后,服务端不断读取客户端发送过来的数据,当客户端与服务端连接断开之后,服务端知道已经读完了⼀条消息,然后进⾏解码和后续处理...。对于第⼆种情况,如果按照上⾯相同的处理逻辑来处理,那就有问题了,我们来看看第⼆种情况下客户端发送的两条消息递交到服务端有可能出现的情况:
第⼀种情况:
服务端⼀共读到两个数据包,第⼀个包包含客户端发出的第⼀条消息的完整信息,第⼆个包包含客户端发出的第⼆条消息,那这种情况⽐较好处理,服务器只需要简单的从⽹络缓冲区去读就好了,第⼀次读到第⼀条消息的完整信息,消费完再从⽹络缓冲区将第⼆条完整消息读出来消费。
没有发⽣粘包、拆包⽰意图
第⼆种情况:
服务端⼀共就读到⼀个数据包,这个数据包包含客户端发出的两条消息的完整信息,这个时候基于之前逻辑实现的服务端就蒙了,因为服务端不知道第⼀条消息从哪⼉结束和第⼆条消息从哪⼉开始,这种情况其实是发⽣了TCP粘包。
TCP粘包⽰意图
第三种情况:
服务端⼀共收到了两个数据包,第⼀个数据包只包含了第⼀条消息的⼀部分,第⼀条消息的后半部分和第⼆条消息都在第⼆个数据包中,或者是第⼀个数据包包含了第⼀条消息的完整信息和第⼆条消息的⼀部分信息,第⼆个数据包包含了第⼆条消息的剩下部分,这种情况其实是发送了TCP拆,因为发⽣了⼀条消息被拆分在两个包⾥⾯发送了,同样上⾯的服务器逻辑对于这种情况是不好处理的。
TCP拆包⽰意图
产⽣tcp粘包和拆包的原因
我们知道tcp是以流动的⽅式传输数据,传输的最⼩单位为⼀个报⽂段(segment)。tcp Header中有个Options标识位,常见的标识为mss(Maximum Segment Size)指的是,连接层每次传输的数据有个最⼤限制MTU(Maximum Transmission Unit),⼀般是1500⽐特,超过这个量要分成多个报⽂段,mss则是这个最⼤限制减去TCP的header,光是要传输的数据的⼤⼩,⼀般为1460⽐特。换算成字节,也就是180多字节。
tcp为提⾼性能,发送端会将需要发送的数据发送到缓冲区,等待缓冲区满了之后,再将缓冲中的数据发送到接收⽅。同理,接收⽅也有缓冲区这样的机制,来接收数据。
发⽣TCP粘包、拆包主要是由于下⾯⼀些原因:
1. 应⽤程序写⼊的数据⼤于套接字缓冲区⼤⼩,这将会发⽣拆包。
2. 应⽤程序写⼊数据⼩于套接字缓冲区⼤⼩,⽹卡将应⽤多次写⼊的数据发送到⽹络上,这将会发⽣粘包。
3. 进⾏mss(最⼤报⽂长度)⼤⼩的TCP分段,当TCP报⽂长度-TCP头部长度>mss的时候将发⽣拆包。
tcp ip协议有哪几层
4. 接收⽅法不及时读取套接字缓冲区数据,这将发⽣粘包。
5. ……
如何解决拆包粘包
既然知道了tcp是⽆界的数据流,且协议本⾝⽆法避免粘包,拆包的发⽣,那我们只能在应⽤层数据协议上,加以控制。通常在制定传输数据时,可以使⽤如下⽅法:
1. 使⽤带消息头的协议、消息头存储消息开始标识及消息长度信息,服务端获取消息头的时候解析出消息长度,然后向后读取该长度的
内容。
2. 设置定长消息,服务端每次读取既定长度的内容作为⼀条完整消息。
3. 设置消息边界,服务端从⽹络流中按消息编辑分离出消息内容。
a)先基于第三种⽅法,假设区分数据边界的标识为换⾏符"\n"(注意请求数据本⾝内部不能包含换⾏符),数据格式为Json,例如下⾯是⼀个符合这个规则的请求包。
{"type":"message","content":"hello"}\n
注意上⾯的请求数据末尾有⼀个换⾏字符(在PHP中⽤双引号字符串"\n"表⽰),代表⼀个请求的结束。
b)基于第⼀种⽅法,可以制定,⾸部固定10个字节长度⽤来保存整个数据包长度,位数不够补0的数据协议
0000000036{"type":"message","content":"hello"}
c)基于第⼀种⽅法,可以制定,⾸部4字节⽹络字节序unsigned int,标记整个包的长度
****{"type":"message","content":"hello all"}
其中⾸部四字节*号代表⼀个⽹络字节序的unsigned int数据,为不可见字符,紧接着是Json的数据格式的包体数据。
基于workerman的解决⽅案
制定了数据协议,那我们下⾯来通过代码具体分析⼀下,php中workerman,是如何解决上述问题的。为了便于理解,可以看下下⾯的流程图
workerman是基于策略模式来设计处理tcp粘包,拆包问题的。具体数据协议的制定在应⽤⽬录Applications/YourApp/Protocols⽬录下,实现则是在框架⽬录Workerman/Connection/TcpConnection.php中。这样的好处就是⽤户可以随意定制⾃⼰的数据协议格式,
⽽框架代码都能处理。
我们现在Applications/YourApp/Protocols⽬录下,建⼀个jsonNL.php,来实现⾃⼰制定⾃⼰定义的数据协议。
JsonNL.php的实现
1.
namespace Protocols;
2.
class JsonNL
3.
{
4.
/**
5.
* 检查包的完整性
6.
* 如果能够得到包长,则返回包的在buffer中的长度,否则返回0继续等待数据7.
* 如果协议有问题,则可以返回false,当前客户端连接会因此断开
8.
* @param string $buffer
9.
* @return int
10.
*/
11.
public static function input($buffer)
12.
{
13.
// 获得换⾏字符"\n"位置
14.
$pos = strpos($buffer, "\n");
15.
// 没有换⾏符,⽆法得知包长,返回0继续等待数据
16.
if($pos === false)
17.
{
18.
return 0;
19.
}
20.
// 有换⾏符,返回当前包长(包含换⾏符)
21.
return $pos+1;
22.
}
23.
24.
/**
25.
* 打包,当向客户端发送数据的时候会⾃动调⽤
26.
* @param string $buffer
27.
* @return string
28.
*/
29.
public static function encode($buffer)
30.
{
31.
// json序列化,并加上换⾏符作为请求结束的标记
32.
return json_encode($buffer)."\n";
33.
}
34.
35.
/**
36.
* 解包,当接收到的数据字节数等于input返回的值(⼤于0的值)⾃动调⽤37.
* 并传递给onMessage回调函数的$data参数
38.
* @param string $buffer
39.
* @return string
40.
*/
41.
public static function decode($buffer)
42.
{
43.
// 去掉换⾏,还原成数组
44.
return json_decode(trim($buffer), true);
45.
}
46.
}
再看下TcpConnection.php中,接收数据时,如何处理。
1.
public function baseRead($socket, $check_eof = true)
2.
{
3.
$buffer = fread($socket, self::READ_BUFFER_SIZE);
4.
5.
// Check connection closed.
6.
if ($buffer === '' || $buffer === false) {
7.
if ($check_eof && (feof($socket) || !is_resource($socket) || $buffer === false)) { 8.
$this->destroy();
9.
return;
10.
}
11.
} else {
12.
$this->_recvBuffer .= $buffer;
13.
}
14.
15.
// If the application layer protocol has been set up.
16.
if ($this->protocol) {
17.
$parser = $this->protocol;
18.
while ($this->_recvBuffer !== '' && !$this->_isPaused) {
19.
// The current packet length is known.
20.
if ($this->_currentPackageLength) {
21.
// Data is not enough for a package.
22.
if ($this->_currentPackageLength > strlen($this->_recvBuffer)) {
23.
break;
24.
}
25.
} else {
26.
// Get current package length.
27.
$this->_currentPackageLength = $parser::input($this->_recvBuffer, $this);
28.
// The packet length is unknown.
29.
if ($this->_currentPackageLength === 0) {
30.
break;
31.
} elseif ($this->_currentPackageLength > 0 && $this->_currentPackageLength <= self::$maxPackageSize) { 32.
// Data is not enough for a package.
33.
if ($this->_currentPackageLength > strlen($this->_recvBuffer)) {
34.
break;
35.
}
36.
} // Wrong package.
37.
else {
38.
echo 'error package. package_length=' . var_export($this->_currentPackageLength, true);
39.
$this->destroy();
40.
return;
41.
}
42.
}
43.
44.
// The data is enough for a packet.
45.
self::$statistics['total_request']++;
46.
// The current packet length is equal to the length of the buffer.
47.
if (strlen($this->_recvBuffer) === $this->_currentPackageLength) {
48.
$one_request_buffer = $this->_recvBuffer;
49.
$this->_recvBuffer = '';
50.
} else {
51.
// Get a full package from the buffer.
52.
$one_request_buffer = substr($this->_recvBuffer, 0, $this->_currentPackageLength);
53.
// Remove the current package from the receive buffer.
54.
$this->_recvBuffer = substr($this->_recvBuffer, $this->_currentPackageLength);
55.
}