什么是粘包問題,為什么我們要講這個看起來比較奇怪的問題呢?
不着急解釋,我們先看一個例子
創建一個server,server端代碼如下
<?php
class TcpBufferServer
{
private $_serv;
/**
* init
*/
public function __construct()
{
$this->_serv = new Swoole\Server("127.0.0.1", 9501);
$this->_serv->set([
'worker_num' => 1,
]);
$this->_serv->on('Receive', [$this, 'onReceive']);
}
public function onReceive($serv, $fd, $fromId, $data)
{
echo "Server received data: {$data}" . PHP_EOL;
}
/**
* start server
*/
public function start()
{
$this->_serv->start();
}
}
$reload = new TcpBufferServer;
$reload->start();
server的代碼很簡單,僅僅是在收到客戶端代碼后,標准輸出一句話而已,client的代碼需要注意了,我們寫了一個for循環,連續向server send三條信息,代碼如下
<?php
$client = new swoole_client(SWOOLE_SOCK_TCP, SWOOLE_SOCK_SYNC);
$client->connect('127.0.0.1', 9501) || exit("connect failed. Error: {$client->errCode}\n");
// 向服務端發送數據
for ($i = 0; $i < 3; $i++) {
$client->send("Just a test.\n");
}
$client->close();
在未運行測試的情況下,我們期望server所在終端輸出的結果應該是這樣的
Server received data: Just a test.
Server received data: Just a test.
Server received data: Just a test.
注意哦,我們期望的結果是server被回調了3次,才有上述期望的結果值
實際運行的結果呢,是否與我們所期望的一致?我們看下
上圖左邊是server輸出的信息。
我們看到,左側顯示的結果是server一次性輸出的結果,按理論來說,client發起了3次請求,server應該跟我們期望的結果一致,會執行3次呀,這怎么回事呢?
這個問題,便是我們今天要說的粘包問題。
為了說清楚這個問題,我們先來看下client/server之間數據傳遞的過程
- 客戶端->發送數據
- 服務端->接收數據
通常我們直覺性的認為,客戶端直接向網絡中傳輸數據,對端從網絡中讀取數據,但是這是不正確的。
socket有緩沖區buffer的概念,每個TCP socket在內核中都有一個發送緩沖區和一個接收緩沖區。客戶端send操作僅僅是把數據拷貝到buffer中,也就是說send完成了,數據並不代表已經發送到服務端了,之后才由TCP協議從buffer中發送到服務端。此時服務端的接收緩沖區被TCP緩存網絡上來的數據,而后server才從buffer中讀取數據。
所以,在onReceive中我們拿到的數據並沒有辦法保證數據包的完整性,swoole_server可能會同時收到多個請求包,也可能只收到一個請求包的一部分數據。
這就是一個大問題呀,如此TCP協議不行呀,這貨雖然能保證我們能正確的接收到數據但是數據不對呀,這麻煩不容小覷。
既然是個問題,那我們自然也就有解決問題的方法,不然我下面說啥呢,對吧。
swoole給我們提供了兩種解決方案
EOF結束協議
EOF,end of file,意思是我們在每一個數據包的結尾加一個eof標記,表示這就是一個完整的數據包,但是如果你的數據本身含有EOF標記,那就會造成收到的數據包不完整,所以開啟EOF支持后,應避免數據中含有EOF標記。
在swoole_server中,我們可以配置open_eof_check為true,打開EOF檢測,配置package_eof來指定EOF標記。
swoole_server收到一個數據包時,會檢測數據包的結尾是否是我們設置的EOF標記,如果不是就會一直拼接數據包,直到超出buffer或者超時才會終止,一旦認定是一個完整的數據包,就會投遞給Worker進程,這時候我們才可以在回調內處理數據。
這樣server就能保證接收到一個完整的數據包了?不能保證,這樣只能保證server能收到一個或者多個完整的數據包。
為啥是多個呢?
我們說了開啟EOF檢測,即open_eof_check設置為true,server只會檢測數據包的末尾是否有EOF標記,如果向我們開篇的案例連發3個EOF的數據,server可能還是會一次性收到,這樣我們只能在回調內對數據包進行拆分處理。
我們拿開篇的案例為例
server開啟eof檢測並指定eof標記是\r\n,代碼如下(完整的代碼都有上傳到github,見文末)
$this->_serv->set([
'worker_num' => 1,
'open_eof_check' => true, //打開EOF檢測
'package_eof' => "\r\n", //設置EOF
]);
客戶端設置發送的數據末尾是\r\n符號,代碼如下
for ($i = 0; $i < 3; $i++) {
$client->send("Just a test.\r\n");
}
按照我們剛才的分析,server的效果可能會一次性收到多個完整的包,我們運行看看結果
因此我們還需要在onReceive回調內對收到的數據進行拆分處理
public function onReceive($serv, $fd, $fromId, $data)
{
// echo "Server received data: {$data}" . PHP_EOL;
$datas = explode("\r\n", $data);
foreach ($datas as $data)
{
if(!$data)
continue;
echo "Server received data: {$data}" . PHP_EOL;
}
}
此時我們再看下運行結果
自行分包的效果便實現了,考慮到自行分包稍微麻煩,swoole提供了open_eof_split配置參數,啟用該參數后,server會從左到右對數據進行逐字節對比,查找數據中的EOF標記進行分包,效果跟我們剛剛自行拆包是一樣的,性能較差。
在案例的基礎上我們看看open_eof_split配置
$this->_serv->set([
'worker_num' => 1,
'open_eof_check' => true, //打開EOF檢測
'package_eof' => "\r\n", //設置EOF
'open_eof_split' => true,
]);
onReceive的回調,我們不需要自行拆包
public function onReceive($serv, $fd, $fromId, $data)
{
echo "Server received data: {$data}" . PHP_EOL;
}
client的測試代碼使用\r\n(同server端package_eof標記一致),我們看下運行效果
EOF標記解決粘包就說這么多,下面我們再看看另一種解決方案
固定包頭+包體協議
下面我們要說的,對於部分同學可能有點難度,對於不理解的,建議多看多操作多問多查,不躲避不畏懼,這樣才能有所提高。
固定包頭是一種非常通用的協議,它的含義就是在你要發送的數據包的前面,添加一段信息,這段信息了包含了你要發送的數據包的長度,長度一般是2個或者4個字節的整數。
在這種協議下,我們的數據包的組成就是包頭+包體。其中包頭就是包體長度的二進制形式。比如我們本來想向服務端發送一段數據 "Just a test." 共12個字符,現在我們要發送的數據就應該是這樣的
pack('N', strlen("Just a test.")) . "Just a test."
其中php的pack函數是把數據打包成二進制字符串。
為什么這樣就能保證Worker進程收到的是一個完整的數據包呢?我來解釋一下:
當server收到一個數據包(可能是多個完整的數據包)之后,會先解出包頭指定的數據長度,然后按照這個長度取出后面的數據,如果一次性收到多個數據包,依次循環,如此就能保證Worker進程可以一次性收到一個完整的數據包。
估計好多人都看蒙了,這都是神馬玩意?我們以案例來分析
server代碼
<?php
class ServerPack
{
private $_serv;
/**
* init
*/
public function __construct()
{
$this->_serv = new Swoole\Server("127.0.0.1", 9501);
$this->_serv->set([
'worker_num' => 1,
'open_length_check' => true, // 開啟協議解析
'package_length_type' => 'N', // 長度字段的類型
'package_length_offset' => 0, //第幾個字節是包長度的值
'package_body_offset' => 4, //第幾個字節開始計算長度
'package_max_length' => 81920, //協議最大長度
]);
$this->_serv->on('Receive', [$this, 'onReceive']);
}
public function onReceive($serv, $fd, $fromId, $data)
{
$info = unpack('N', $data);
$len = $info[1];
$body = substr($data, - $len);
echo "server received data: {$body}\n";
}
/**
* start server
*/
public function start()
{
$this->_serv->start();
}
}
$reload = new ServerPack;
$reload->start();
客戶端的代碼
<?php
$client = new swoole_client(SWOOLE_SOCK_TCP, SWOOLE_SOCK_SYNC);
$client->connect('127.0.0.1', 9501) || exit("connect failed. Error: {$client->errCode}\n");
// 向服務端發送數據
for ($i = 0; $i < 3; $i++) {
$data = "Just a test.";
$data = pack('N', strlen($data)) . $data;
$client->send($data);
}
$client->close();
運行的結果
結果沒錯,是我們期望的結果。
我們來分析下這是為什么
1、首先,在server端我們配置了open_length_check,該參數表明我們要開啟固定包頭協議解析
2、package_length_type配置,表明包頭長度的類型,這個類型跟客戶端使用pack打包包頭的類型一致,一般設置為N或者n,N表示4個字節,n表示2個字節
3、我們看下客戶端的代碼 pack('N', strlen($data)) . \(data,這句話就是包頭+包體的意思,包頭是pack函數打包的二進制數據,內容便是真實數據的長度 strlen(\)data)。
在內存中,整數一般占用4個字節,所以我們看到,在這段數據中0-4字節表示的是包頭,剩余的就是真實的數據。但是server不知道呀,怎么告訴server這一事實呢?
看配置package_length_offset和package_body_offset,前者就是告訴server,從第幾個字節開始是長度,后者就是從第幾個字節開始計算長度。
4、既然如此,我們就可以在onReceive回調對數據解包,然后從包頭中取出包體長度,再從接收到的數據中截取真正的包體。
$info = unpack('N', $data);
$len = $info[1];
$body = substr($data, - $len);
echo "server received data: {$body}\n";
這便是swoole對於粘包問題的解決,你學會了嗎?有任何問題下面留言哦。
文中設計到的源碼較多,對應的文件名同文中截圖時各終端運行的腳本名一致,參考如下