swoole 之 tcp 合包分包

PHP技术
411
0
0
2022-07-09
标签   Swoole

下面通过两个例子,了解 tcp 传输没有数据边界的特点所带来的问题,由此引出本篇提出的合包与分包的概念。

在此使用 swoole 的客户端和服务端。

例1,发送方发送多条数据,接收方一次性读取

//发送方
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);
for ($i=0; $i < 11; $i++) {
    $client->send("hello!");// 一次发送较小的数据,多次发送。
}
$client->close();

上面客户端发送了 11 次「hello!」,那么服务端接收是什么样的呢?

//接收方
$serv = new swoole_server("127.0.0.1", 6001);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    var_dump($data);
});
$serv->start();

打印结果

swoole 之 tcp 合包分包

和预期不同,服务端并没有接收 11 次,11 个「hello!」 黏在了一起!

例2, 发送方发送一条大量数据,接收方分多次读取

//发送方
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);
$client->send(str_repeat('a',32*1024));//发送一条较大的数据
$client->close();

上面客户端发送了一次 32kb 的数据,服务端如何接收呢?

//接收方
$serv = new swoole_server("127.0.0.1", 6001);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    var_dump(strlen($data));
});
$serv->start();

打印结果

swoole 之 tcp 合包分包

服务端并没有一次读取,而是读取了 5次!

tcp 传输没有数据边界

//创建一个TCP套接字
$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);

传输类型 SOCK_STREAM 代表面向连接的套接字(stream流)。它被形象的比喻为「传送带」。TCP 协议即基于这种流式套接字。

特征:

  • 可靠
  • 顺序传输
  • 没有数据边界

swoole 之 tcp 合包分包

上图来自《tcp/ip网络编程》

左侧将一个个数据包放到传送带上,右侧为了提高效率,并非一有数据马上 read,而是存在一个缓冲区 (buffer),可能在缓冲区满后一次性读取,也可能未满时多次读取。

也就是说,发送方发送多条数据,接收方可能一次性读取;或者发送方发送一条大量数据,接收方分多次读取。在面向连接的套接字中,read 函数和 write函数调用次数并无太大意义。

针对上方例子tcp传输没有数据边界的处理办法

swoole文档中给了两种解决方案。这两种方案,swoole底层会进行数据包拼接,确保每次回调都能得到完整的包($data)。

处理方法1,EOF (end of file)

通过特定的分隔符来确认完整的数据(即使用分隔符来界定数据的边界。)

//接收方
$serv = new swoole_server("127.0.0.1", 6001);
$serv->set([
    'open_eof_split'=>true,
    'package_eof'=>"\r\n\r\n" 
]);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    var_dump(strlen($data));
});
$serv->start();

开启eof后数据结尾需加上自定义结束符,否则接收方无法接收到数据。下面再演示上方例1与例2,发送方在数据末尾加上分隔符

例1(eof)

//发送多条数据
for ($i=0; $i < 11; $i++) {
    $client->send("hello!"."\r\n\r\n"); //约定以\r\n\r\n为分隔符
}

swoole 之 tcp 合包分包

例2(eof)

//发送一条较大的数据
$client->send(str_repeat('a',32*1024)."\r\n\r\n");

swoole 之 tcp 合包分包

可见,文章开头的例1例2中的问题得到了解决。

处理方法2,固定包头+包体

EOF方法,需保证数据中不能包含eof字符,否则会发生截取的数据不正确,但实际并不能保证数据中不包含eof字符。并且在截取数据时采用遍历数据进行eof字符匹配,有一定的性能消耗。因此,通常使用包头+包体的方法。

swoole 之 tcp 合包分包

原理:

swoole 之 tcp 合包分包

在数据 data 前,用几个字节保存 data 的长度,接收方根据长度来截取数据 data

如包体 data='aaaaa',包头用 2个字节 保存 data 长度 5

swoole 之 tcp 合包分包

接收方收到包后,先解析二进制格式包头,解析出 5,代表包体的长度为 5 。包的总长度为 7,从第2字节(因包头固定占用2个字节长度)开始截取 5 的长度的数据。 data = substr(包, 2) 得到 aaaaa

那么接下来的重点是如何定义包头:

  1. 确保包头的长度固定(用多少个字节保存数据的长度),让接收方知道从包的哪个位置开始截取(偏移量),因为数据长度是不确定的。
  2. 其次包头的固定长度尽量的小,不占用过多资源,那么使用二进制来存贮是非常合适的。
  3. 不同的计算机保存和解析数据时顺序是不一致的(主机字节序)。如整数值1在发送方的存贮方式是这样的:00000000 00000000 00000000 00000001,如果接收方和发送方的主机字节序相反,它保存的是:00000001 00000000 00000000 00000000。打个不恰当的例子,如发送方发送1234,先发送高位的1(千位),接收方接收到1,因它和发送方保存数据的顺序正好相反,它先保存低位的,1则被保存到最低位1(个位),接收完变成4321。因此出现个概念叫“网络字节序”统一发送数据的顺序,接收方按照固定的函数将这种顺序的数据转成自己主机的主机字节序保存。例如统一发送顺序为4(个)-3(十)-2(百)-1(千) —–>接收方清楚1是高位的。

swoole 文档中 Server > 配置选项 > package_length_type列举了包头的类型。

swoole 之 tcp 合包分包

那么选择无符号的、网络字节序。即NnN 能表示更多的整数值。

//接收方
$serv = new swoole_server("127.0.0.1", 6001);
$serv->set([
    'open_length_check' => true, //开启打开包长检测特性
    'package_max_length' => 32*1024, //包的最大长度,过大会占用较多的内存
    'package_length_type' => 'N', //包头长度类型
    'package_length_offset' => 0,  //length长度值在包头的第几个字节
    'package_body_offset' => 4, //从第几个字节开始计算包体长度(N为4个字节)
]);
$serv->on('receive', function ($serv, $fd, $from_id, $data) {
   $len = unpack('N',$data);
   $body = substr($data,4,$len[1]);
   var_dump($body);
});
$serv->start();
//发送方
$client = new swoole_client(SWOOLE_SOCK_TCP);
$client->connect('127.0.0.1', 6001, -1);

$body = 'aaaaa';
$head = pack('N',strlen($body));//打包
$pack = $head.$body; 

for ($i=0; $i < 6 ; $i++) {
    $client->send($pack);
}
$client->close();

发送方发送了 6次 aaaaa

swoole 之 tcp 合包分包

最后补充,文中的例子都是客户端向服务端发送消息,而服务端向客户端发送的信息,也需要合包分包,代码是一样的。

<?php //客户端
$client = new swoole_client(SWOOLE_SOCK_TCP);

//注意同步客户端 设置选项在connect之前!
$client->set(array(
    'open_length_check'     => 1,
    'package_length_type'   => 'N',
    'package_length_offset' => 0,  
    'package_body_offset'   => 4,   
    'package_max_length'    => 100*1024, 
));

$client->connect('127.0.0.1', 6001, -1);

$data = $client->recv();
if($data){
    $len = unpack('N',$data);
    $body = substr($data,4,$len[1]);
    var_dump(strlen($body));
}

$client->close();