Swoole - TCP流資料邊界問題解決方案

ligkwww發表於2020-05-19
[TOC]

前言

最近在學習Swoole時發現可以透過配置就可以解決TCP在傳輸資料時產生的“粘包”問題, 以前都是自己手動來解決的, 尷尬 - -||, 對這裡進行深入瞭解一下,學習過程也記錄下來,後面想到什麼了及時補充進來。

1.資料傳送過程

首先由客戶端將資料發往緩衝區(服務端並不是直接收到的), 對於客戶端來說,這次的資料即是傳送成功了, 對於服務端是否真正的收到他是不知道的, 然後再由服務端從緩衝區中讀取資料。圖解:

image

2.什麼是資料邊界

因為 TCP 是流式傳輸,對於服務端來說並不知道此時在緩衝區內的資料是一次請求還是兩次請求的,所以在服務端接收資料時需要根據指定字元或約定長度來對資料進行分包,這個分包的標誌即是資料邊界。否則可能會出現一次讀取兩條或多條資料,造成讀取、解析資料出錯。

image

2.1 程式碼演示

可以用程式碼實現一下,假設客戶端死迴圈往緩衝區不停輸入“1”,即相當於每次的報文內容都是1, 那麼在服務端讀取時收到的資料就是隨機長度的。

客戶端程式碼
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

if ($client->connect('127.0.0.1', 9501, -1)) {
    while(true) {
        $client->send(1);        
    }
}
$client->close();
服務端程式碼
$server = new Swoole\Server('127.0.0.1', 9501);

$server->on('connect', function($server, $fd){
    echo "client : ".$fd." connect";
});

$server->on('receive', function($server, $fd, $from_id, $data){
    echo "receive:". $data.PHP_EOL;
});

$server->on('close', function($server){

});
執行結果

image

可以看到執行結果,服務端獲取到的資料完全是隨機的,有長有短,那麼接下來我們說下如何解決這個問題。

3.EOF解決方案

第一種解決方案類似於我們http請求頭的分隔符,在每次傳送的資料包結尾處使用 \r\n (可以配置)來結尾, 當服務端從緩衝區中讀取資料, 根據指定字元來分割資料包,EOF有兩種配置方案:

3.1 open_eof_check

首先放出配置方式:

$server->set([
    'open_eof_check' => true,
    'package_eof' => "\r\n"
]);

這種配置方式會對客戶端發來的資料包進行檢測, 當發現結尾是 \r\n 時,才會投遞給worker程式, 也就是我們的 onReceive 回撥,否則會一直拼接資料包,直到超出緩衝區或者超時才終止。 但此方法有一個問題是可能會一次性收到多個資料包,因為他是從資料包的結尾處來進行檢查的,在資料內容中存在 \r\n 時程式並不會發現,需要我們自己在應用程式碼中再次使用 \r\n 來拆分資料包。

客戶端執行程式碼
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

if ($client->connect('127.0.0.1', 9501, -1)) {

    while(true) {
        $send2 = "Hello World \r\n";
        $client->send($send2);        
    }
}

$client->close();
服務端程式碼
$server = new Swoole\Server('127.0.0.1', 9501);
$server->set([
    'open_eof_check' => true,
    'package_eof' => "\r\n"
]);

$server->on('connect', function($server, $fd){
    echo "client : ".$fd." connect";
});

$server->on('receive', function($server, $fd, $from_id, $data){
    echo "receive:". $data;
});

$server->on('close', function($server){

});

$server->start();
執行結果

image

3.2 open_eof_split

配置方式:

$server->set([
    'open_eof_split' => true,
    'package_eof' => "\r\n"
]);

這種配置方式,服務端會對客戶端發來的資料逐個字元進行檢查,遇到 \r\n 就傳送給worker程式,可以有效實現分包,但缺點是效能比較差。
執行結果:可以看到每次接收到一個 Hello World(程式碼我就不貼了, 只把服務端set配置改一下, 其他都一樣)
image

3.3 open_eof_check 和 open_eof_split 差異
  • open_eof_check 只檢查接收資料的末尾是否為 EOF,因此它的效能最好,幾乎沒有消耗
  • open_eof_check 無法解決多個資料包合併的問題,比如同時傳送兩條帶有 EOF 的資料,底層可能會一次全部返回
  • open_eof_split 會從左到右對資料進行逐位元組對比,查詢資料中的 EOF 進行分包,效能較差。但是每次只會返回一個資料包

4.固定包頭+包體解決方案

引用一段官方文件的描述:

包長檢測提供了固定包頭 + 包體這種格式協議的解析。啟用後,可以保證 Worker 程式 onReceive 每次都會收到一個完整的資料包。
長度檢測協議,只需要計算一次長度,資料處理僅進行指標偏移,效能非常高,推薦使用。

可見官方是推薦使用這種方式的,就是配置比其他方案要複雜一些, 首先貼一下配置:

$server->set([
// 開啟包長檢測特性
'package_length_check' => true,
// 包頭中某個欄位作為包長度的值,底層支援了 10 種長度型別。可參考 pack() 方法
'package_length_type' => 'N',
// length 長度值在包頭的第幾個位元組。
'package_length_offset' => 8,
// 從第幾個位元組開始計算長度,一般有 2 種情況:
//length 的值包含了整個包(包頭 + 包體),package_body_offset 為 0
//包頭長度為 N 位元組,length 的值不包含包頭,僅包含包體,package_body_offset 設定為 N
'package_body_offset' => 16,
// 設定最大資料包尺寸,單位為位元組
'package_max_length' => 81920
]);

下面是一個資料包結構例子,可以很好的體現了欄位含義。

image

以上通訊協議的設計中,包頭長度為 4 個整型,16 位元組,length 長度值在第 3 個整型處。因此 package_length_offset 設定為 8,0-3 位元組為 type,4-7 位元組為 uid,8-11 位元組為 length,12-15 位元組為 serid。

下面來說一下程式碼實現:

客戶端程式碼:
$client = new Swoole\Client(SWOOLE_SOCK_TCP);

$data = "123456789012345678901234567890";
$type = 0x30;
$uid = 0x123;
$length = strlen($data);
$serid = 0x15;
$head = pack("N4", $type, $uid, $length, $serid);
$body = pack("a{$length}", $data);
$message = $head.$body;


if ($client->connect('127.0.0.1', 9502, -1)) {
    $client->send($message);
    echo $client->recv();
}

$client->close();
服務端程式碼:
$serv = new Swoole\Server('127.0.0.1', 9502);

$serv->set([
    'open_length_check'     => true,
      'package_max_length'    => 81920,
      'package_length_type'   => 'N',
      'package_length_offset' => 8,
      'package_body_offset'   => 16,    
]);

$serv->on('connect', function($server, $fd){
    echo $fd. " Connect !".PHP_EOL;
});

$serv->on('receive', function($server, $fd, $from_id, $data){
    var_dump($data);            // 源資料
    $tmp = unpack("Ntype/Nuid/Nlength", $data);
    $unpacking = unpack("Ntype/Nuid/Nlength/Nserid/a{$tmp['length']}body", $data);
    var_dump($unpacking);        // 解包後資料
    $server->send($fd, " Server Receive Data: ". $unpacking['body']);
});


$serv->on('close', function($server){

});

$serv->start();
客戶端執行結果

image

服務端執行結果

image

可以看到 客戶端成功的把傳送的資料回顯, 服務端也列印出了接收到的所有資料, 其中有些欄位在傳送時是16進位制的, 所以服務端在接收到之後需要進行進位制轉換, 我這裡沒有進行轉換, 所以顯示的資料是10進位制的。

5.總結

透過對比可以看出使用固定包頭 + 包體的方式是效率最高的一種, 因為他是按照固定長度去讀取的。期間專門去了解了 pack 函式的使用方法,但也不確定這麼寫到底對不對,如果有其他了解的仁兄可以慷慨解答一下,網上相關資料有點少,官方文件上也只給出了幾個欄位的釋義。

6.擴充套件知識:

6.1位元組序

計算機硬體有兩種儲存資料的方式:大端位元組序(big endian)和小端位元組序(little endian)。

舉例來說,數值0x2211使用兩個位元組儲存:高位位元組是0x22,低位位元組是0x11。

  • 大端位元組序:高位位元組在前,低位位元組在後,這是人類讀寫數值的方法。
  • 小端位元組序:低位位元組在前,高位位元組在後,即以0x1122形式儲存。

這個前和後指的是記憶體地址,計算機處理位元組時是不知道高低位元組之分的,它只知道按順序讀取位元組,先讀第一個位元組,再讀第二個位元組。

例如: 0x1234567的讀取順序:

image

參考資料:

www.ruanyifeng.com/blog/2016/11/byt...

www.cnblogs.com/nr-zhang/p/9989390...

wiki.swoole.com/#/server/setting?i...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章