深入讲解swoole处理粘包问题

环境如下

  • swoole 4.4.12
  • PHP 7.3.5 (cli) (built: May 6 2019 11:38:17) ( NTS )

一、粘包的概念

  • 官方解释:粘包,指TCP协议中,发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。
  • 通俗解释:所谓粘包就是,一个数据在发送的时候跟上了另一个数据的信息,另一个数据的信息可能是完整的也可能是不完整的。

二、造成粘包的原因

出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。

  1. 发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。 若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成包后一次发送出去,这样接收方就收到了粘包数据;
  2. 接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。

三、粘包的解决

解决粘包的方法,就是分包。所谓分包,是指在出现粘包的时候我们的接收方要进行分包处理。
在长连接中分包的时候, 数据包的边界如果发生错位, 导致读出错误的数据分包,进而曲解原始数据含义,这点需要特别注意。

  1. 特殊字符分割
    通过定义一个特殊的符号于package_eof,注意是客户端与服务端相互约定的,然后下一步就是客户端每次发送的时候都会在后面添加这个数据,服务端做数据的字符串处理(本质就是字符串切割的思路,在php中就是利用explode函数来处理)
    但是这种处理方式也存在问题,就是如果数据包中本身就带有package_eof,在做字符串切割处理的时候,会造成错误的分包,从而曲解原始数据的意思。
  2. 固定包头+包体协议
    通过定义好发送消息的tcp数据包的格式规范,对于服务端和客户端相互之间同时遵守这个规范,这种规范就是在tcp的数据包中携带上数据的长度,接收端(可能是服务端也可能是客户端)就可以根据这个长度,对接收到的数据进行截取,从而实现分包的目的。
    这里补充需要用到两个基础的函数:
    pack函数:https://php.golaravel.com/function.pack.html
    unpack函数:https://php.golaravel.com/function.unpack.html
    须知:一个字节 = 8个二进制位
    如果对上面对pack和unpack函数不能够很好的理解,先跳过,看下面的代码实现,可助于理解

四、代码实现

  1. 传统方式实现
  • 代码

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

执行结果如下图


image.png

或者开两个个命令行窗口,分别执行如下命令:

[root@localhost swoole_04]#  php tcp_server.php
[root@localhost swoole_04]#  php tcp_async_client.php

执行结果如下图


image.png

可以看出,不管是同步客户端还是异步客户端,都与服务端同样实现了粘包的处理

  1. 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

执行结果如下图


image.png

或者开两个命令行窗口,分别执行如下命令:

[root@localhost swoole_04]#  php swoole_tcp_server.php
[root@localhost swoole_04]#  php swoole_tcp_async_client.php

执行结果如下图


image.png

现在对pack、unpack函数应该更加能够理解了吧,喜欢记得点个赞~

五、总结

  1. 不管是原生的方式还是swoole的方式来实现粘包处理,原理都是一样的,都是在服务端和客户端共同约定相同的数据发送、接收规范(pack打包和unpack解包的方式),大家都遵循这套规范就行了;
  2. 对于swoole的方式,在配置open_length_check等内容的时候,要注意:
    • 实例化完swoole的server或client(包括同步客户端和异步客户端)时,就要立即配置好swoole的包长检测
    • 谁接收,谁来配置好swoole的包长检测。在上面的例子中,刚好服务端和客户端都需要接收数据,所以都配置了

最后,希望本文对大家能够有帮助,如有不足之处,欢迎指正!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容