环境如下
- swoole 4.4.12
- PHP 7.3.5 (cli) (built: May 6 2019 11:38:17) ( NTS )
一、粘包的概念
- 官方解释:粘包,指TCP协议中,发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。
- 通俗解释:所谓粘包就是,一个数据在发送的时候跟上了另一个数据的信息,另一个数据的信息可能是完整的也可能是不完整的。
二、造成粘包的原因
出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。
- 发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。 若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成包后一次发送出去,这样接收方就收到了粘包数据;
- 接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。
三、粘包的解决
解决粘包的方法,就是分包。所谓分包,是指在出现粘包的时候我们的接收方要进行分包处理。
在长连接中分包的时候, 数据包的边界如果发生错位, 导致读出错误的数据分包,进而曲解原始数据含义,这点需要特别注意。
- 特殊字符分割
通过定义一个特殊的符号于package_eof,注意是客户端与服务端相互约定的,然后下一步就是客户端每次发送的时候都会在后面添加这个数据,服务端做数据的字符串处理(本质就是字符串切割的思路,在php中就是利用explode函数来处理)
但是这种处理方式也存在问题,就是如果数据包中本身就带有package_eof,在做字符串切割处理的时候,会造成错误的分包,从而曲解原始数据的意思。 - 固定包头+包体协议
通过定义好发送消息的tcp数据包的格式规范,对于服务端和客户端相互之间同时遵守这个规范,这种规范就是在tcp的数据包中携带上数据的长度,接收端(可能是服务端也可能是客户端)就可以根据这个长度,对接收到的数据进行截取,从而实现分包的目的。
这里补充需要用到两个基础的函数:
pack函数:https://php.golaravel.com/function.pack.html
unpack函数:https://php.golaravel.com/function.unpack.html
须知:一个字节 = 8个二进制位
如果对上面对pack和unpack函数不能够很好的理解,先跳过,看下面的代码实现,可助于理解
四、代码实现
- 传统方式实现
- 代码
tcp_server.php 服务端代码
<?php
/**
* Create by PhpStorm
* User : Actor
* Date : 2019-12-24
* Time : 21:02
*/
//创建Server对象,监听 127.0.0.1:9501端口
use Swoole\Server;
$serv = new Swoole\Server("127.0.0.1", 9501);
//监听连接进入事件
$serv->on('Connect', function ($serv, $fd) {
//echo "WorkerClient ".$fd.": Connect.\n";
$str = "我是服务端";
$len = pack('n', strlen($str));
$context = $len.$str;
for($i=0; $i<10; $i++){
$serv->send($fd, $context);
}
});
//监听数据接收事件
$start = 0;
$serv->on('Receive', function ($serv, $fd, $from_id, $data) use (&$start) {
// 接收客户端的信息(手动拆包)
for ($i=0; $i<10; $i++){
//因为这里,客户端/服务端的打包/解包的方式是'n',查看手册,
//是16位(占用2个字节),所以,截取0~2的字符串,再解包,就是客户端所发送数据的长度M了
$pack = unpack('n', substr($data, $start,2));
$len = $pack[1];//客户端所发送数据的长度M
$start = ($len + 2) * ($i+1);//维护$start,下一段数据包截取的起点
$context = substr($data, 2*($i+1)+$len*$i, $len);//从start~M截取数据包,获得客户端所发的完整真实的数据
echo '收到信息:' . $context . "\r\n";
sleep(1);//为了便于演示效果
}
});
//监听连接关闭事件
$serv->on('Close', function ($serv, $fd) {
echo "WorkerClient: ".$fd."Close.\n" ;
});
echo "启动swoole tcp server 访问地址 127.0.0.1:9501 \n";
//启动服务器
$serv->start();
tcp_sync_client.php 同步客户端代码
<?php
/**
* Create by PhpStorm
* User : Actor
* Date : 2019-12-24
* Time : 21:04
*/
$client = new swoole_client(SWOOLE_SOCK_TCP);
//连接到服务器
if (!$client->connect('127.0.0.1', 9501, 0.5)) {
die("connect failed.");
}
//向服务器发送数据
$context = '我是同步客户端';
$len = pack('n', strlen($context));
//在发送数据的头部拼接$context长度的二进制值。为什么要二进制,因为只要确定了pack的打包方式,那么,这个二进制所占用的字节长度N是固定了,
//这样子,只要保持客户端和服务端的打包/解包方式一致,那么服务端拿到数据,只要从0到N截取数据包,就能获得到客户端发送数据的长度M,
//然后再从N开始截取长度为M的字符串,就是客户端发送的真实数据了
$send = $len.$context;
for ($i=0; $i<10; $i++){
$client->send($send);
}
if (!$client->send("hello world")) {
die("send failed.");
}
//从服务器接收数据
$data = $client->recv();
for ($i=0; $i<10; $i++){
//因为这里,客户端/服务端约定的打包/解包的方式是'n',查看手册,
//是16位(占用2个字节),所以,截取0~2的字符串,再解包,就是客户端所发送数据的长度M了
$pack = unpack('n', substr($data, $start,2));
$len = $pack[1];//客户端所发送数据的长度M
$start = ($len + 2) * ($i+1);//维护$start,下一段数据包截取的起点
$context = substr($data, 2*($i+1)+$len*$i, $len);//从start~M截取数据包,获得客户端所发的完整真实的数据
echo '收到信息:' . $context . "\r\n";
sleep(1);//为了便于演示效果
}
$client->close();
tcp_async_client.php 异步客户端代码
<?php
/**
* Create by PhpStorm
* User : Actor
* Date : 2020-03-29
* Time : 12:06
*/
use Swoole\Async\Client;
$client = new Client(SWOOLE_SOCK_TCP);
$client->on("connect", function(Client $cli) {
//$cli->send("GET / HTTP/1.1\r\n\r\n");
//向服务器发送数据
$context = '我是异步客户端';
$len = pack('n', strlen($context));
//在发送数据的头部拼接$context长度的二进制值。为什么要二进制,因为只要确定了pack的打包方式,那么,这个二进制所占用的字节长度N是固定了,
//这样子,只要保持客户端和服务端的打包/解包方式一致,那么服务端拿到数据,只要从0到N截取数据包,就能获得到客户端发送数据的长度M,
//然后再从N开始截取长度为M的字符串,就是客户端发送的真实数据了
$send = $len.$context;
for ($i=0; $i<10; $i++){
$cli->send($send);
}
});
//接收服务端发送过来的数据
$client->on("receive", function(Client $cli, $data){
for ($i=0; $i<10; $i++){
//因为这里,客户端/服务端约定的打包/解包的方式是'n',查看手册,
//是16位(占用2个字节),所以,截取0~2的字符串,再解包,就是客户端所发送数据的长度M了
$pack = unpack('n', substr($data, $start,2));
$len = $pack[1];//客户端所发送数据的长度M
$start = ($len + 2) * ($i+1);//维护$start,下一段数据包截取的起点
$context = substr($data, 2*($i+1)+$len*$i, $len);//从start~M截取数据包,获得客户端所发的完整真实的数据
echo '收到信息:' . $context . "\r\n";
sleep(1);//为了便于演示效果
}
});
$client->on("error", function(Client $cli){
echo "error\n";
});
$client->on("close", function(Client $cli){
echo "Connection close\n";
});
$client->connect('127.0.0.1', 9501);
- 测试
接着开两个命令行窗口,分别执行如下命令:
[root@localhost swoole_04]# php tcp_server.php
[root@localhost swoole_04]# php tcp_sync_client.php
执行结果如下图
或者开两个个命令行窗口,分别执行如下命令:
[root@localhost swoole_04]# php tcp_server.php
[root@localhost swoole_04]# php tcp_async_client.php
执行结果如下图
可以看出,不管是同步客户端还是异步客户端,都与服务端同样实现了粘包的处理
- swoole的方式处理粘包
- 代码
swoole_tcp_server.php 服务端代码
<?php
/**
* Create by PhpStorm
* User : Actor
* Date : 2019-12-24
* Time : 21:02
*/
//创建Server对象,监听 127.0.0.1:9501端口
$serv = new Swoole\Server("127.0.0.1", 9501);
//配置swoole的包长检测
$serv->set([
'open_length_check' => true,
'package_max_length' => 81920,
'package_length_type' => 'n',
'package_length_offset' => 0,
'package_body_offset' => 2,
]);
//监听连接进入事件
$serv->on('Connect', function ($serv, $fd) {
//echo "WorkerClient ".$fd.": Connect.\n";
$str = "我是服务端";
$len = pack('n', strlen($str));
$context = $len.$str;
for($i=0; $i<10; $i++){
$serv->send($fd, $context);
}
});
//监听数据接收事件
$start = 0;
$serv->on('Receive', function ($serv, $fd, $from_id, $data) use (&$start) {
// 接收客户端的信息(手动拆包)
//开启了open_length_check,接收数据后直接打印出来
$info = $data."\r\n";
echo "收到消息:".$info;
sleep(1);//为了便于演示效果
});
//监听连接关闭事件
$serv->on('Close', function ($serv, $fd) {
echo "WorkerClient: ".$fd."Close.\n" ;
});
echo "启动swoole tcp server 访问地址 127.0.0.1:9501 \n";
//启动服务器
$serv->start();
swoole_tcp_sync_client.php 同步客户端代码
<?php
/**
* Create by PhpStorm
* User : Actor
* Date : 2019-12-24
* Time : 21:04
*/
$client = new swoole_client(SWOOLE_SOCK_TCP);
//配置swoole的包长检测
$client->set([
'open_length_check' => true,
'package_max_length' => 81920,
'package_length_type' => 'n',
'package_length_offset' => 0,
'package_body_offset' => 2,
]);
//连接到服务器
if (!$client->connect('127.0.0.1', 9501, 0.5)) {
die("connect failed.");
}
//向服务器发送数据
$context = '我是同步客户端';
$len = pack('n', strlen($context));
//在发送数据的头部拼接$context长度的二进制值。为什么要二进制,因为只要确定了pack的打包方式,那么,这个二进制所占用的字节长度N是固定了,
//这样子,只要保持客户端和服务端的打包/解包方式一致,那么服务端拿到数据,只要从0到N截取数据包,就能获得到客户端发送数据的长度M,
//然后再从N开始截取长度为M的字符串,就是客户端发送的真实数据了
$send = $len.$context;
for ($i=0; $i<10; $i++){
$client->send($send);
}
if (!$client->send("hello world")) {
die("send failed.");
}
//从服务器接收数据
//使用swoole的包长检测机制实现拆包
for ($i=0; $i<10; $i++){
$data = $client->recv();
//开启了open_length_check,接收数据后直接打印出来
$info = $data."\r\n";
echo "收到消息:".$info;
sleep(1);//为了便于演示效果
}
$client->close();
swoole_tcp_async_client.php 异步客户端代码
<?php
/**
* Create by PhpStorm
* User : Actor
* Date : 2020-03-29
* Time : 12:06
*/
use Swoole\Async\Client;
$client = new Client(SWOOLE_SOCK_TCP);
//配置swoole的包长检测
$client->set([
'open_length_check' => true,
'package_max_length' => 81920,
'package_length_type' => 'n',
'package_length_offset' => 0,
'package_body_offset' => 2,
]);
$client->on("connect", function(Client $cli) {
//$cli->send("GET / HTTP/1.1\r\n\r\n");
//向服务器发送数据
$context = '我是异步客户端';
$len = pack('n', strlen($context));
//在发送数据的头部拼接$context长度的二进制值。为什么要二进制,因为只要确定了pack的打包方式,那么,这个二进制所占用的字节长度N是固定了,
//这样子,只要保持客户端和服务端的打包/解包方式一致,那么服务端拿到数据,只要从0到N截取数据包,就能获得到客户端发送数据的长度M,
//然后再从N开始截取长度为M的字符串,就是客户端发送的真实数据了
$send = $len.$context;
for ($i=0; $i<10; $i++){
$cli->send($send);
}
});
//从服务端接收数据
$client->on("receive", function(Client $cli, $data){
//开启了open_length_check,接收数据后直接打印出来
$info = $data."\r\n";
echo "收到消息:".$info;
sleep(1);//为了便于演示效果
});
$client->on("error", function(Client $cli){
echo "error\n";
});
$client->on("close", function(Client $cli){
echo "Connection close\n";
});
$client->connect('127.0.0.1', 9501);
- 测试
接着开两个命令行窗口,分别执行如下命令:
[root@localhost swoole_04]# php swoole_tcp_server.php
[root@localhost swoole_04]# php swoole_tcp_sync_client.php
执行结果如下图
或者开两个命令行窗口,分别执行如下命令:
[root@localhost swoole_04]# php swoole_tcp_server.php
[root@localhost swoole_04]# php swoole_tcp_async_client.php
执行结果如下图
现在对pack、unpack函数应该更加能够理解了吧,喜欢记得点个赞~
五、总结
- 不管是原生的方式还是swoole的方式来实现粘包处理,原理都是一样的,都是在服务端和客户端共同约定相同的数据发送、接收规范(pack打包和unpack解包的方式),大家都遵循这套规范就行了;
- 对于swoole的方式,在配置open_length_check等内容的时候,要注意:
- 实例化完swoole的server或client(包括同步客户端和异步客户端)时,就要立即配置好swoole的包长检测
- 谁接收,谁来配置好swoole的包长检测。在上面的例子中,刚好服务端和客户端都需要接收数据,所以都配置了
最后,希望本文对大家能够有帮助,如有不足之处,欢迎指正!