Qt中使用TCP接收報文

manxisuo發表於2022-11-23

假設有一個TCP服務端,會向連線到它的TCP客戶端週期(或隨機)傳送一個報文。報文由定長的報文頭和不定長的報文體(資料部分)組成,報文體是一張圖片,每個位元組表示圖片中一個畫素的灰度值。我們的任務就是讀取報文,解析圖片內容,儲存或顯示圖片。
報文頭的格式如下:

#define FLAG 0x12131415
struct Header
{
    quint32 flag;   // 報文標識
    quint32 length; // 報文長度
    quint32 width;  // 圖片寬度
    quint32 height; // 圖片高度
}

flag是報文標識,用來識別報文的開始;length是報文的總長度,透過它可以知道報文何時結束;widthheight表示圖片的高度和寬度,用來將報文體資料解析為圖片。

首先構造一個QTcpScoket用於和服務端建立TCP連線,等待接收資料。

QTcpSocket *socket = new QTcpSocket(this);
//socket->setReadBufferSize(BUF_SIZE);
connect(socket, SIGNAL(readyRead()), this, SLOT(slotReadData()));

測試發現,如果服務端一次傳送的報文長度很長(例如10086位元組),會被分割成多個包傳送。下面是透過tcpdump命令抓包得到的:

14:47:27.438893 IP ...... length 1448
14:47:27.438916 IP ...... length 1448
14:47:27.438920 IP ...... length 1448
14:47:27.438922 IP ...... length 1448
14:47:27.438980 IP ...... length 1448
14:47:27.438983 IP ...... length 1448
14:47:27.438985 IP ...... length 1398

此例中,10068個位元組被分割成7個包。在QTcpSocket接收到資料後,每個包會對應地發射一次readyRead()訊號。也就是說,在槽函式中,只能讀取整個報文的一部分。因此需要定義一些成員變數,來儲存資料讀取過程中的狀態。

private:
    QTcpSocket *mSocket;
    char mBuf[BUF_SIZE];     // 資料讀取緩衝區
    qint64 mSize = 0;        // 已讀取資料的長度
    Header mHeader;          // 報文頭
    bool mHeadValid = false; // 報文頭是否有效

接下來我們開始讀取資料,有兩種思路。

方式一

先讀取報文頭,再讀取報文體,讀完一個報文,再嘗試讀下一個。

void Client::slotReadData()
{
    while (true)
    {
        qint64 readSize = mSocket->read(mBuf + mSize, getMaxDataSize());
        if (readSize == 0) break;

        mSize += readSize;

        // 讀取頭
        if (!mHeadValid && mSize >= sizeof(Header))
        {
            Header *header = (Header*)mBuf;
            if (header->flag == FLAG)
            {
                mHeadValid = true;
                mHeader = *header;
            }
        }

        // 處理完整資料
        if (mHeadValid && mSize == mHeader.length)
        {
            dealData();
            mHeadValid = false;
            mSize = 0;
        }
    }
}

程式碼的基本思路是,先嚐試讀取報文頭,根據報文標識定位報文頭。讀到報文頭後,即可得到報文總長度和其他資訊,此時將報文頭有效標識設為true。接下來繼續讀資料,當已讀取的資料長度等於報文頭中告知的報文總長度時,完成當前報文的讀取,此時需要重置mHeadValidmSize的值,為讀取下一個報文做準備。其中dealData()函式用於處理報文資料,例如儲存圖片。

另外,程式碼中還有一個getMaxDataSize()函式,用來獲取當前期望讀取的資料的最大長度,定義如下:

qint64 Client::getMaxDataSize()
{
    if (mHeadValid) return mHeader.length - mSize;
    else return sizeof(Header) - mSize;
}

方式二

先讀取報文頭,再讀取報文體,每次讀取儘量多的資料。程式碼如下:

void Client::slotReadData()
{
    while (true)
    {
        qint64 readSize = mSocket->read(mBuf + mSize, getMaxDataSize());
        if (readSize == 0) break;

        mSize += readSize;

        // 讀取頭
        if (!mHeadValid && mSize >= sizeof(Header))
        {
            Header *header = (Header*)mBuf;
            if (header->flag == FLAG)
            {
                mHeadValid = true;
                mHeader = *header;
            }
        }

        // 處理完整資料
        if (mHeadValid && mSize == mHeader.length)
        {
            dealData();
            qint64 left = mSize - mHeader.length;
            if (left > 0) memmove(mBuf, mBuf + mHeader.length, left);
            mHeadValid = false;
            mSize = left;
        }
    }
}

在這種方式下,getMaxDataSize()函式的定義如下:

qint64 Client::getMaxDataSize()
{
    return BUF_SIZE - mSize;
}

與第一種方式的區別在於,在讀完一個報文時,當前報文後會存在下一個報文的開始部分。因此需要將這部分資料移到緩衝區的開始位置。也就是說,我們假定出現了兩個報文的內容交疊在一個包中的情況。在兩個報文間隔時間較長的情況下,是不應該出現這種情況的。那麼什麼情況下會出現呢?實驗發現,在資料傳送過快(TODO)或者網路斷開一段時間後又連線導致服務端擠壓的大量資料在段時間內傳送出去時,會出現這種情況。實際上,方式一也能夠處理這種情況。因此,這兩種接收TCP報文的方式,都是可以的。

另外,經過測試發現:TCP服務端發出的報文資料,總是被拆分為大小為1448的包。但是——尤其在資料傳送資料過快時——readyRead()訊號的發射次數,以及每次發射時可以讀取到的資料大小,與此並不一致。大致情況是,每次讀到的資料大小傾向於是1448的整數倍,看起來是Qt底層把N個包合併在了一起。另外,槽函式的執行耗時也影響後續每次讀取的資料大小。當前的槽函式耗時越長,下一個槽函式讀到的資料越多。總之,你不能預測readyRead()什麼時候發射,以及每次發射時能讀取的資料有多少。

關於為什麼是1448位元組和關於TCP分段,可以參考這篇文章:TCP分段 & IP分片

相關文章