Работа с сетью в Qt

Реализация простого чат-протокола на QTcpSocket: сервер и клиент

Введение

В предыдущих статьях мы рассмотрели QTcpSocket и QTcpServer по отдельности. Теперь соберём из них небольшой, но полноценный протокол обмена сообщениями: с идентификацией пользователей, форматом сообщений на основе JSON и корректной обработкой границ сообщений в потоке TCP. Это хорошая база для понимания, как строятся собственные прикладные протоколы поверх «сырого» TCP.

Концепция

Поскольку TCP — потоковый протокол без границ сообщений, нужен способ определить, где заканчивается одно сообщение и начинается другое. Распространённый подход — префиксировать каждое сообщение его длиной в виде фиксированного заголовка (например, 4 байта quint32), а тело сообщения — в JSON. Получатель сначала ждёт, пока в буфере накопится достаточно байт для заголовка, считывает из него длину, а затем ждёт, пока накопится всё тело сообщения целиком.

Такой формат «длина + данные» универсален, не зависит от содержимого (не нужно экранировать байты-разделители) и легко реализуется как на клиенте, так и на сервере одинаковым кодом.

Пример кода: Реализация простого чат-протокола

#include <QTcpSocket>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDataStream>
#include <QDebug>

// Общая функция отправки сообщения с префиксом длины
void sendJsonMessage(QTcpSocket *socket, const QJsonObject &obj)
{
    QByteArray payload = QJsonDocument(obj).toJson(QJsonDocument::Compact);
    QByteArray packet;
    QDataStream stream(&packet, QIODevice::WriteOnly);
    stream << static_cast<quint32>(payload.size());
    packet.append(payload);
    socket->write(packet);
}

// Класс, инкапсулирующий разбор потока на отдельные JSON-сообщения
class MessageParser
{
public:
    // Возвращает список полностью разобранных JSON-объектов
    QList<QJsonObject> feed(const QByteArray &chunk)
    {
        m_buffer.append(chunk);
        QList<QJsonObject> result;

        while (true) {
            if (m_buffer.size() < static_cast<int>(sizeof(quint32)))
                break;

            quint32 length;
            QDataStream stream(m_buffer);
            stream >> length;

            const int headerSize = sizeof(quint32);
            if (m_buffer.size() < headerSize + static_cast<int>(length))
                break;

            QByteArray payload = m_buffer.mid(headerSize, length);
            m_buffer.remove(0, headerSize + length);

            QJsonParseError error;
            QJsonDocument doc = QJsonDocument::fromJson(payload, &error);
            if (error.error == QJsonParseError::NoError && doc.isObject()) {
                result.append(doc.object());
            }
        }
        return result;
    }

private:
    QByteArray m_buffer;
};

// Пример использования на стороне клиента
void onReadyReadExample(QTcpSocket *socket, MessageParser &parser)
{
    QObject::connect(socket, &QTcpSocket::readyRead, [socket, &parser]() {
        const auto messages = parser.feed(socket->readAll());
        for (const QJsonObject &msg : messages) {
            qDebug() << "Получено сообщение от" << msg["from"].toString()
                     << ":" << msg["text"].toString();
        }
    });
}

Пояснения к коду

Функция sendJsonMessage() сериализует JSON-объект в компактный QByteArray, записывает перед ним 4-байтовую длину через QDataStream и отправляет всё одним вызовом write(). Класс MessageParser решает обратную задачу: накапливает входящие байты в m_buffer и в цикле извлекает из него готовые сообщения, проверяя, что в буфере достаточно данных для заголовка длины и для самого тела сообщения — если данных не хватает, разбор приостанавливается до следующего вызова feed().

Такой парсер можно использовать симметрично и на клиенте, и на сервере — единственное отличие будет в логике обработки конкретных полей сообщения (например, type, from, text).

Подводные камни

  • Эндианность при сериализации длины. QDataStream по умолчанию использует big-endian порядок байт — это нормально, если оба конца используют QDataStream, но при взаимодействии с не-Qt системами нужно явно согласовывать порядок байт.
  • Отсутствие ограничения на максимальную длину сообщения в парсере открывает возможность для атаки: злоумышленник может отправить заголовок с огромным значением длины, и сервер будет ждать «тело» сообщения, накапливая память в буфере. Добавляйте проверку length > MAX_MESSAGE_SIZE и разрывайте соединение при превышении.
  • Частичная запись (write() не гарантирует отправку всех байт за один вызов) — для больших сообщений нужно либо дождаться сигнала bytesWritten(), либо довериться внутренней буферизации Qt (которая обычно сама докидывает оставшиеся данные), но не предполагать атомарность операции на уровне ОС.
  • Совместное использование одного MessageParser несколькими соединениями недопустимо — каждому клиентскому сокету нужен свой экземпляр парсера с собственным буфером, иначе сообщения разных клиентов будут перемешиваться.