Введение
В предыдущих статьях мы рассмотрели 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несколькими соединениями недопустимо — каждому клиентскому сокету нужен свой экземпляр парсера с собственным буфером, иначе сообщения разных клиентов будут перемешиваться.