tcp粘包和拆包的原因及处理方案
随着智能硬件越来越流行,很多后端开发人员都有可能接触到socket编程。而很多情况下,服务器与端上需要保证数据的有序,稳定到达,自然而然就会选择基于tcp/ip协议的socekt开发。开发过程中,经常会遇到tcp粘包,拆包的问题,本文将从产生原因,和解决方案以及workerman是如何处理粘包拆包问题的,这几个层面来说明这个问题。
什么是粘包拆包
对于什么是粘包、拆包问题,我想先举两个简单的应用场景:
-
客户端和服务器建立一个连接,客户端发送一条消息,客户端关闭与服务端的连接。
-
客户端和服务器简历一个连接,客户端连续发送两条消息,客户端关闭与服务端的连接。
对于第一种情况,服务端的处理流程可以是这样的:当客户端与服务端的连接建立成功之后,服务端不断读取客户端发送过来的数据,当客户端与服务端连接断开之后,服务端知道已经读完了一条消息,然后进行解码和后续处理...。对于第二种情况,如果按照上面相同的处理逻辑来处理,那就有问题了,我们来看看第二种情况下客户端发送的两条消息递交到服务端有可能出现的情况:
第一种情况:
服务端一共读到两个数据包,第一个包包含客户端发出的第一条消息的完整信息,第二个包包含客户端发出的第二条消息,那这种情况比较好处理,服务器只需要简单的从网络缓冲区去读就好了,第一次读到第一条消息的完整信息,消费完再从网络缓冲区将第二条完整消息读出来消费。
没有发生粘包、拆包示意图
第二种情况:
服务端一共就读到一个数据包,这个数据包包含客户端发出的两条消息的完整信息,这个时候基于之前逻辑实现的服务端就蒙了,因为服务端不知道第一条消息从哪儿结束和第二条消息从哪儿开始,这种情况其实是发生了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粘包、拆包主要是由于下面一些原因:
-
应用程序写入的数据大于套接字缓冲区大小,这将会发生拆包。
-
应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包。
-
进行mss(最大报文长度)大小的TCP分段,当TCP报文长度-TCP头部长度>mss的时候将发生拆包。
-
接收方法不及时读取套接字缓冲区数据,这将发生粘包。
-
……
如何解决拆包粘包
既然知道了tcp是无界的数据流,且协议本身无法避免粘包,拆包的发生,那我们只能在应用层数据协议上,加以控制。通常在制定传输数据时,可以使用如下方法:
-
使用带消息头的协议、消息头存储消息开始标识及消息长度信息,服务端获取消息头的时候解析出消息长度,然后向后读取该长度的内容。
-
设置定长消息,服务端每次读取既定长度的内容作为一条完整消息。
-
设置消息边界,服务端从网络流中按消息编辑分离出消息内容。
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的实现
-
namespace Protocols;
-
class JsonNL
-
{
-
/**
-
* 检查包的完整性
-
* 如果能够得到包长,则返回包的在buffer中的长度,否则返回0继续等待数据
-
* 如果协议有问题,则可以返回false,当前客户端连接会因此断开
-
* @param string $buffer
-
* @return int
-
*/
-
public static function input($buffer)
-
{
-
// 获得换行字符"\n"位置
-
$pos = strpos($buffer, "\n");
-
// 没有换行符,无法得知包长,返回0继续等待数据
-
if($pos === false)
-
{
-
return 0;
-
}
-
// 有换行符,返回当前包长(包含换行符)
-
return $pos+1;
-
}
-
-
/**
-
* 打包,当向客户端发送数据的时候会自动调用
-
* @param string $buffer
-
* @return string
-
*/
-
public static function encode($buffer)
-
{
-
// json序列化,并加上换行符作为请求结束的标记
-
return json_encode($buffer)."\n";
-
}
-
-
/**
-
* 解包,当接收到的数据字节数等于input返回的值(大于0的值)自动调用
-
* 并传递给onMessage回调函数的$data参数
-
* @param string $buffer
-
* @return string
-
*/
-
public static function decode($buffer)
-
{
-
// 去掉换行,还原成数组
-
return json_decode(trim($buffer), true);
-
}
-
}
再看下TcpConnection.php中,接收数据时,如何处理。
-
public function baseRead($socket, $check_eof = true)
-
{
-
$buffer = fread($socket, self::READ_BUFFER_SIZE);
-
-
// Check connection closed.
-
if ($buffer === '' || $buffer === false) {
-
if ($check_eof && (feof($socket) || !is_resource($socket) || $buffer === false)) {
-
$this->destroy();
-
return;
-
}
-
} else {
-
$this->_recvBuffer .= $buffer;
-
}
-
-
// If the application layer protocol has been set up.
-
if ($this->protocol) {
-
$parser = $this->protocol;
-
while ($this->_recvBuffer !== '' && !$this->_isPaused) {
-
// The current packet length is known.
-
if ($this->_currentPackageLength) {
-
// Data is not enough for a package.
-
if ($this->_currentPackageLength > strlen($this->_recvBuffer)) {
-
break;
-
}
-
} else {
-
// Get current package length.
-
$this->_currentPackageLength = $parser::input($this->_recvBuffer, $this);
-
// The packet length is unknown.
-
if ($this->_currentPackageLength === 0) {
-
break;
-
} elseif ($this->_currentPackageLength > 0 && $this->_currentPackageLength <= self::$maxPackageSize) {
-
// Data is not enough for a package.
-
if ($this->_currentPackageLength > strlen($this->_recvBuffer)) {
-
break;
-
}
-
} // Wrong package.
-
else {
-
echo 'error package. package_length=' . var_export($this->_currentPackageLength, true);
-
$this->destroy();
-
return;
-
}
-
}
-
-
// The data is enough for a packet.
-
self::$statistics['total_request']++;
-
// The current packet length is equal to the length of the buffer.
-
if (strlen($this->_recvBuffer) === $this->_currentPackageLength) {
-
$one_request_buffer = $this->_recvBuffer;
-
$this->_recvBuffer = '';
-
} else {
-
// Get a full package from the buffer.
-
$one_request_buffer = substr($this->_recvBuffer, 0, $this->_currentPackageLength);
-
// Remove the current package from the receive buffer.
-
$this->_recvBuffer = substr($this->_recvBuffer, $this->_currentPackageLength);
-
}
-
// Reset the current packet length to 0.
-
$this->_currentPackageLength = 0;
-
if (!$this->onMessage) {
-
continue;
-
}
-
try {
-
// Decode request buffer before Emitting onMessage callback.
-
call_user_func($this->onMessage, $this, $parser::decode($one_request_buffer, $this));
-
} catch (\Exception $e) {
-
Worker::log($e);
-
exit(250);
-
} catch (\Error $e) {
-
Worker::log($e);
-
exit(250);
-
}
-
}
-
return;
-
}
-
-
if ($this->_recvBuffer === '' || $this->_isPaused) {
-
return;
-
}
-
-
// Applications protocol is not set.
-
self::$statistics['total_request']++;
-
if (!$this->onMessage) {
-
$this->_recvBuffer = '';
-
return;
-
}
-
try {
-
call_user_func($this->onMessage, $this, $this->_recvBuffer);
-
} catch (\Exception $e) {
-
Worker::log($e);
-
exit(250);
-
} catch (\Error $e) {
-
Worker::log($e);
-
exit(250);
-
}
-
// Clean receive buffer.
-
$this->_recvBuffer = '';
-
}
上面的代码比较多,不需要细读,几个关键的地方可以看出处理的思路,先把接收的数据包追加到_recvBuffer变量中,然后调用用户自己定义的数据协议中的input方法。input方法则会判断数据中是否包含边界符,如果不包含则返回0,包含则返回当前数据包的大小。框架中接收到input的返回值后,如果接收值为0,则跳出循环不做处理,如果接收值不为0,则将截取的数据包赋值给one_request_buffer,并且重置_recvBuffer
-
// Get a full package from the buffer.
-
$one_request_buffer = substr($this->_recvBuffer, 0, $this->_currentPackageLength);
-
// Remove the current package from the receive buffer.
-
$this->_recvBuffer = substr($this->_recvBuffer, $this->_currentPackageLength);
最后:tcp虽然是个强大的协议,能保证数据的稳定性,一致性,但在实际开发中,我们还需要根据实际的数据协议,来控制每次获取的包是客户端发过来的一个完整的可以解析的包。