13. swoole基礎-swoole之粘包問題


什么是粘包問題,為什么我們要講這個看起來比較奇怪的問題呢?

不着急解釋,我們先看一個例子

創建一個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次,才有上述期望的結果值

實際運行的結果呢,是否與我們所期望的一致?我們看下

img

上圖左邊是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的效果可能會一次性收到多個完整的包,我們運行看看結果

img

因此我們還需要在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;
    }
}

此時我們再看下運行結果

img

自行分包的效果便實現了,考慮到自行分包稍微麻煩,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標記一致),我們看下運行效果

img

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();

運行的結果

img

結果沒錯,是我們期望的結果。

我們來分析下這是為什么

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對於粘包問題的解決,你學會了嗎?有任何問題下面留言哦。

文中設計到的源碼較多,對應的文件名同文中截圖時各終端運行的腳本名一致,參考如下

tcp-buffer-server.php

tcp-buffer-client.php

server-eof-check.php

server-eof-client.php

server-eof-split.php

server-pack.php

client-pack.php


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM