下面通過兩個例子,瞭解 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();
列印結果
和預期不同,服務端並沒有接收 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();
列印結果
服務端並沒有一次讀取,而是讀取了 5次!
tcp
//建立一個TCP套接字
$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
傳輸型別 SOCK_STREAM 代表面向連線的套接字(stream流)。它被形象的比喻為「傳送帶」。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為分隔符
}
例2(eof)
//傳送一條較大的資料
$client->send(str_repeat('a',32*1024)."\r\n\r\n");
可見,文章開頭的例1例2中的問題得到了解決。
處理方法2,固定包頭+包體
EOF方法,需保證資料中不能包含eof字元,否則會發生擷取的資料不正確,但實際並不能保證資料中不包含eof字元。並且在擷取資料時採用遍歷資料進行eof字元匹配,有一定的效能消耗。因此,通常使用包頭+包體的方法。
原理:
在資料 data
前,用幾個位元組儲存 data
的長度,接收方根據長度來擷取資料 data
。
如包體 data='aaaaa'
,包頭用 2個位元組
儲存 data
長度 5
。
接收方收到包後,先解析二進位制格式包頭,解析出 5
,代表包體的長度為 5
。包的總長度為 7
,從第2位元組(因包頭固定佔用2個位元組長度)開始擷取 5
的長度的資料。 data = substr(包, 2)
得到 aaaaa
。
那麼接下來的重點是如何定義包頭:
確保包頭的長度固定(用多少個位元組儲存資料的長度),讓接收方知道從包的哪個位置開始擷取(偏移量),因為資料長度是不確定的。
其次包頭的固定長度儘量的小,不佔用過多資源,那麼使用二進位制來存貯是非常合適的。
不同的計算機儲存和解析資料時順序是不一致的(主機位元組序)。如整數值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
列舉了包頭的型別。
那麼選擇無符號的、網路位元組序。即N
、n
。N
能表示更多的整數值。
//接收方
$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
最後補充,文中的例子都是客戶端向服務端傳送訊息,而服務端向客戶端傳送的資訊,也需要合包分包,程式碼是一樣的。
<?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();
本作品採用《CC 協議》,轉載必須註明作者和本文連結