PProto: бинарный rpc протокол для Qt framework (часть 1)

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Думаю, не сильно ошибусь, если скажу, что каждый разработчик программного обеспечения рано или поздно сталкивается с задачей взаимодействия приложений расположенных на удаленных узлах локальной или глобальной сети. В разных проектах мне довелось поработать с DCOM, SOAP, самописным (не моим) rpc-протоколом, использующим связку json+boost.asio. Проблематика коммуникации приложений и процессов мне всегда была интересна. Пытаясь разобраться, как устроены различные механизмы взаимодействия, ставя эксперименты по сериализации данных, со временем, я пришел к решению, о котором хочу рассказать.

PProto - (point-протокол) симметричный1 коммуникационный протокол, используемый для передачи команд и данных. Первичные требования к протоколу определены следующими тезисами:

  • бинарная сериализация данных 2 ;

  • поддержка обратной совместимости структур данных в механизме сериализации (версионирование);

  • ориентация на проекты, написанные на C++/Qt (используются потоковые операторы Qt);

  • приоритет удобства использования протокола разработчиком перед универсальностью формата.

Материал разделен на две части. В первой части речь пойдет о сериализации данных, во второй - о концепции команд, сообщениях, и механизмах доставки.

Предпосылки

Прежде чем перейти к техническим деталям, нужно сделать некоторые пояснения: почему протокол бинарный, как это связано с Qt, и причем тут версионирование.
Первоначально не было намерений создавать новый протокол. Была необходимость взаимодействия между отдельными приложениями в пределах одного хоста. В рамках этой потребности выбор формата сериализации стал первым шагом. Рассматривались два варианта: текст3 или бинарное представление. Ниже представлены плюсы и минусы обоих форматов.

Текстовая сериализация (плюсы)

  • почти беспроблемное взаимодействие между различными платформами и языками программирования;

  • удобство в отладке.

Текстовая сериализация (минусы)

  • объем сериализованных данных больше исходного (иногда существенно);

  • дополнительные вычислительные расходы на преобразование в текст и обратно;

  • невысокая скорость сериализации/десериализации по сравнению с бинарными вариантами.

Бинарная сериализация (плюсы)

  • высокая скорость сериализации/десериализации;

  • размер сериализованных данных такой же как у оригинальной структуры в памяти4.

Бинарная сериализация (минусы)

  • неудобно/трудно отлаживать;

  • повышенные требования к реализации функций упаковки/распаковки данных (невнимательность программиста может запросто привести к аварийному завершению программы);

  • сложности с версионированием.

С текстовой сериализацией все более-менее понятно - универсально, но медленно. Бинарная сериализация казалась более проблемной, но скорость и объем это весомые аргументы. На момент принятия решения я уже несколько лет работал с Qt-framework и знал, что у этой библиотеки есть класс для бинарной сериализации QDataStream. Большим плюсом QDataStream является то, что помимо фундаментальных типов С++ он может сериализовывать практически все Qt-типы. Кроссплатформенность Qt делает решение на основе QDataStream еще более привлекательным. Нерешенным оставался вопрос с версионированием, об этом будет сказано ниже.

[1] После того, как соединение установлено, взаимодействующие приложения становятся симметрично-равнозначными. Они могут обмениваться командами и данными в режиме полного дуплекса. Концепция клиент-сервер (ведущий-ведомый) используется только на этапе создания соединения. На архитектурном уровне парадигма клиент-сервер может сохраняться.

[2] Сейчас, в дополнение к бинарной, реализована json-сериализация, теоретически предусмотрена возможность поддержки других форматов.

[3] Под "текстом" понимаются любые человекочитаемые форматы представления данных: JSON, XML, и пр.

[4] Для некоторых форматов, например protobuf, размер сериализованных данных может быть меньше исходных.

Сериализация

Простейшая сериализация с использованием QDataStream выглядит так:

struct A
{    
    qint32  value1;
    QString value2;
};
QDataStream& operator<< (QDataStream& stream, const A& a) 
{
    stream << a.value1;
    stream << a.value2;    
}
QDataStream& operator>> (QDataStream& stream, A& a) 
{    
    stream >> a.value1;    
    stream >> a.value2;    
}

В рабочем коде конструкция будет следующей:

// Сериализация
A a;
QByteArray ba;
{
    QDataStream stream {&ba, QIODevice::WriteOnly};
    stream << a;
}

// Десериализация
A a2;
{
    QDataStream stream {ba};    
    stream >> a2;
}

Немного изменим пример, введем функции toRaw(), fromRaw(). Они потребуются для изложения дальнейшего материала.

struct A
{
    qint32  value1;
    QString value2;
    QByteArray toRaw() const;
    void fromRaw(const QByteArray&);
};
QByteArray A::toRaw() const
{
    QByteArray ba;
    QDataStream stream {&ba, QIODevice::WriteOnly};
    stream << value1;
    stream << value2;
    return ba;
}
void A::fromRaw(const QByteArray& ba)
{
    QDataStream stream {ba};
    stream >> value1;
    stream >> value2;
}

// Сериализация
A a;
QByteArray ba = a.toRaw();

// Десериализация
A a2;
a2.fromRaw(ba);

Версионирование

Обычно, для того чтобы версионировать структуру данных, в ее начало добавляют поле с информацией о версии.

struct A
{
    quint8 version = {0};
    ...
};

Это достаточно простой и распространенный прием. Он хорошо подходит для сохранения состояния программы (конфигурации), а также для хранения рабочих данных. Каждая новая версия структуры хранит в себе информацию о форматах упаковки, которые использовались ранее, поэтому прочитать бинарные данные младшей версии не составляет особого труда. Запись данных происходит уже в новом формате. Как правило, такой механизм версионирования хорошо работает только "вверх", то есть от младших версий к старшим. Так, предыдущая версия программы не сможет прочитать данные записанные в новом формате. В лучшем случае, программа выдаст сообщение: "Неизвестный формат данных. Попробуйте обновить приложение". Затем, завершит свою работу. В худшем случае - получим аварийную остановку.

Описанный выше подход имеет серьезное ограничение, когда речь идет об обмене данными между приложениями. Взаимодействующие приложения должны "знать" версии сериализованных структур данных, получаемых друг от друга, а это не всегда возможно. Представим, что у нас есть некая распределенная система вычислений, которая включает в себя несколько видов приложений (микросервисов). Микросервисы расположены на разных серверах в сети. Один из микросервисов было решено модифицировать, причем изменения затронули формат данных, использующийся для обмена с другими сервисами. Как следствие, пришлось увеличить версию сериализации для нескольких структур. Новую версию микросервиса развернули только на одном сервере. Обновленный микросервис может принимать входные данные от других сервисов системы, но его ответы, увы, никто понять не может. Конечно, можно еще немного пофантазировать и предположить, что новый микросерсис должен отвечать той же версией сериализации, с которой был получен запрос. Реализация и поддержка подобного механизма может оказаться трудоемкой. Идти по этому пути я не решился.

Пассивное версионирование

Как показывает практика, большинство задач по модификации структур данных связаны с их расширением или усечением. Потребность в кардинальной модификации (смена типов полей) возникает достаточно редко.
Класс QDataStream хорошо справляется с задачей расширения. Допустим в структуру A нужно добавить поле value3 с типом float. Новую версию структуры назовем A_new.

struct A
{
    qint32  value1;
    QString value2;
    QByteArray toRaw() const;
    void fromRaw(const QByteArray&);
};
QByteArray A::toRaw() const
{
    QByteArray ba;
    QDataStream stream {&ba, QIODevice::WriteOnly};
    stream << value1;
    stream << value2;
    return ba;
}
void A::fromRaw(const QByteArray& ba)
{
    QDataStream stream {ba};
    stream >> value1;
    stream >> value2;
}

struct A_new
{
    qint32  value1;
    QString value2;
    float   value3 = {0}; // Новое поле
    QByteArray toRaw() const;
    void fromRaw(const QByteArray&);
};
QByteArray A_new::toRaw() const
{
    QByteArray ba;
    QDataStream stream {&ba, QIODevice::WriteOnly};
    stream << value1;
    stream << value2;
    stream << value3;
    return ba;
}
void A_new::fromRaw(const QByteArray& ba)
{
    QDataStream stream {ba};
    stream >> value1;
    stream >> value2;
    stream >> value3;
}

A a;
QByteArray ba = a.toRaw();

A_new a_new;
a_new.fromRaw(ba); // OK, поле value3 будет иметь значение по умолчанию

a_new.value3 = 5;
ba = a_new.toRaw();

A a2;
a2.fromRaw(ba); // OK, поле value3 не будет прочитано из QDataStream

В приведенном примере, передача данных от A к A_new будет происходить корректно: QDataStream контролирует процесс вычитывания полей из своего буфера данных. При попытке прочитать value3, будет выполнена проверка на достижение конца буфера данных потока. Если конец буфера потока достигнут, то поле value3 не будет прочитано. Последнее утверждение справедливо для всех вновь добавленных полей идущих за value3. Обратная передача от A_new к A так же будет происходить корректно. Структура A прочитает только известные ей поля, а оставшиеся данные буду проигнорированы.
Теперь рассмотрим усечение. У нас есть структура B, из нее нужно убрать поле value1. Новую версию структуры назовем B_new.

struct B
{
    qint32  value1 = {0};
    QString value2;
    float   value3 = {0};
    QByteArray toRaw() const;
    void fromRaw(const QByteArray&);
};
QByteArray B::toRaw() const
{
    QByteArray ba;
    QDataStream stream {&ba, QIODevice::WriteOnly};
    stream << value1;
    stream << value2;
    stream << value3;
    return ba;
}
void B::fromRaw(const QByteArray& ba)
{
    QDataStream stream {ba};
    stream >> value1;
    stream >> value2;
    stream >> value3;
}

struct B_new
{
    QString value2;
    float   value3 = {0};
    QByteArray toRaw() const;
    void fromRaw(const QByteArray&);
};
QByteArray B_new::toRaw() const
{
    QByteArray ba;
    QDataStream stream {&ba, QIODevice::WriteOnly};
    stream << qint32(0); // value1 dummy    
    stream << value2;
    stream << value3;
    return ba;
}
void B_new::fromRaw(const QByteArray& ba)
{
    QDataStream stream {ba};
  
    // Фиктивное вычитывание параметра value1    
    qint32 value1Dummy;    
    stream >> value1Dummy; (void) value1Dummy;
    // Альтернативный вараинт
    // stream.skipRawData(sizeof(qint32));
  
    stream >> value2;
    stream >> value3;    
}

Из кода примера видно, что фактически усечение происходит только внутри структуры B_new, в функциях сериализации/десериализации по-прежнему нужно записывать и считывать значение поля value1, пусть и фиктивно. По другому не получится, иначе будет нарушен порядок размещения данных в буфере QDataStream. Немного смягчает ситуацию то обстоятельство, что потери не очень большие: в приведенном примере всего 4 байта. С Qt-типами тоже все не так плохо: для QString, QVector, QList, QHash, и пр. потери составят те же 4 байта (размер переменной для хранения длины контейнера). С пользовательскими типами дело обстоит хуже, но и тут можно найти выход. Если пользовательский тип агрегировать в структуру данных как смарт-пойнт, то получим от 4 до 16 байт потерь, в зависимости от конструктива смарт-пойнтера.
Описанный механизм требует придерживаться трех простых, но очень важных правил:

  • порядок следования полей в функциях сериализации изменяться не должен; новые поля должны добавляться после существующих, то есть нельзя новое поле value3 читать или писать в поток данных перед уже существующим полем value2;

  • поля, имеющие фундаментальный тип или являющиеся перечислениями, должны быть проинициализированы значениями по умолчанию;

  • при удалении поля из структуры данных, это поле должно продолжать присутствовать в функциях сериализации как фиктивный параметр.

Отступление от этих правил грозит аварийным завершением программы и ночными бдениями за отладчиком.

Избыточное версионирование

Потребности в изменении структур данных не ограничивается только их расширением или усечением. Иногда требуется более радикальная модификация, речь идет о смене типа для поля. В предыдущем разделе были описаны три правила, достаточно жестко регламентирующие сериализацию полей. Если нельзя писать измененное поле в один и тот же поток, то почему бы не записать в альтернативный? То есть каждую версию структуры данных писать в свой поток, при этом совершенно не обязательно повторять сериализацию всей структуры, достаточно будет записывать только изменившиеся поля. В коде идея будет выглядеть так:

struct C
{
    qint32  value1 = {0};
    quint32 value2 = {0};
    float   value3 = {0};
    QVector<QByteArray> toRaw() const;
    void fromRaw(const QVector<QByteArray>&);
};
QVector<QByteArray> C::toRaw() const
{
    QVector<QByteArray> vect;
    {
        QByteArray ba;
        QDataStream stream {&ba, QIODevice::WriteOnly};
        stream << value1;
        stream << value2;
        stream << value3;
        vect.append(ba);
    }
    return vect;
}
void C::fromRaw(const QVector<QByteArray>& vect)
{
    if (vect.count() >= 1)
    {
        const QByteArray& ba = vect.at(0);
        QDataStream stream {ba};
        stream >> value1;
        stream >> value2;
        stream >> value3;
     }
}

struct C_new
{
    qint32  value1 = {0};
    QString value2; // Изменен тип поля
    float   value3 = {0};
    QVector<QByteArray> toRaw() const;
    void fromRaw(const QVector<QByteArray>&);
};
QVector<QByteArray> C_new::toRaw() const
{
    QVector<QByteArray> vect;    
    { //Версия 1
        QByteArray ba;
        QDataStream stream {&ba, QIODevice::WriteOnly};
        stream << value1;
        stream << qHash(value2); // Сохраняем совместимость со старой версией,
                                 // записываем какое-то осмысленное числовое значение
        stream << value3;
        vect.append(ba);
    }
    { //Версия 2
        QByteArray ba;
        QDataStream stream {&ba, QIODevice::WriteOnly};
        stream << value2; // Записываем поле в новом строковом формате
        vect.append(ba);
    }
    return vect;
}
void C::fromRaw(const QVector<QByteArray>& vect)
{
    // Версия 1
    if (vect.count() >= 1)
    {
        const QByteArray& ba = vect.at(0);
        QDataStream stream {ba};
        stream >> value1;
        quint32 oldValue2;
        stream >> oldValue2;
        value2 = QString::number(oldValue2); // При необходимости можно заполнить
                                             // поле значением из версии 1
        stream >> value3;
    }
    // Версия 2
    if (vect.count() >= 2)
    {
        const QByteArray& ba = vect.at(1);
        QDataStream stream {ba};
        stream >> value2;    
    }
}

Магия макросов позволяет немного сократить код функций, улучшить их читаемость.

QVector<QByteArray> C::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << value1;
    stream << value2;
    stream << value3;
    B_SERIALIZE_RETURN
}
void C::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> value1;
    stream >> value2;
    stream >> value3;
    B_DESERIALIZE_END
}

QVector<QByteArray> C_new::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << value1;
    stream << qHash(value2); // Сохраняем совместимость со старой версией, 
                             // записываем какое-то осмысленное числовое значение    
    stream << value3;      
    B_SERIALIZE_V2(stream)
    stream << value2; // Записываем поле в новом строковом формате  
    B_SERIALIZE_RETURN
}
void C::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> value1;
    quint32 oldValue2;
    stream >> oldValue2;
    value2 = QString::number(oldValue2); // При необходимости можно заполнить 
                                         // поле значением из версии 1
    stream >> value3;
    B_DESERIALIZE_V2(vect, stream)
    stream >> value2;
    B_DESERIALIZE_END
}

Реализацию макросов можно посмотреть в модуле qbinary. Максимальное количество версий сериализации равно 255. Такое число версий совершенно избыточно, просто столько помещается в переменную размером один байт. В моей практике, количество версий для структуры не превышало трех. Механизм версионирования не отменяет требование тщательной проработки интерфейса взаимодействия. Он поможет скорректировать ошибки связанные с неопределенностями первичного этапа разработки, но от небрежности проектирования не спасет.

Агрегирование

Пользовательские структуры не всегда состоят только из стандартных типов, часто они включают (агрегируют) другие пользовательские типы данных. Ниже представлен пример сериализации агрегированных структур:

struct F1
{
    qint8   value1 = {0};
    quint32 value2 = {0};
    bserial::RawVector toRaw() const;
    void fromRaw(const bserial::RawVector&);
};
bserial::RawVector F1::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << value1;
    stream << value2;
    B_SERIALIZE_RETURN
}
void F1::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> value1;
    stream >> value2;
    B_DESERIALIZE_END
}

struct F2
{
    QString value3;
    QVector<qint32> value4;
    bserial::RawVector toRaw() const;
    void fromRaw(const bserial::RawVector&);
};
bserial::RawVector F2::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << value3;
    stream << value4;
    B_SERIALIZE_RETURN
}
void F2::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> value3;
    stream >> value4;
    B_DESERIALIZE_END
}

struct S
{
    F1 field1;
    QList<F2> field2;
    bserial::RawVector toRaw() const;
    void fromRaw(const bserial::RawVector&);
};
bserial::RawVector S::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << field1;
    stream << field2;
    B_SERIALIZE_RETURN
}
void S::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> field1;
    stream >> field2;
    B_DESERIALIZE_END
}

Чтобы пример заработал, потребуются потоковые операторы для типов F1, F2 (возвращаемся к первому примеру раздела Сериализация). Сейчас нужно сделать пояснение, почему потоковые операторы заменены на функции toRaw(), fromRaw():

  • использование функций сокращает количество временных объектов QDataStream, что в некоторых ситуациях позволяет улучшить производительность;

  • поля структуры данных могут быть приватными, поэтому обобщенные потоковые операторы необходимо декларировать как friend-функции. Не все старые компиляторы такую конструкцию понимают.

Когда структуры данных имеют функции сериализации, нет необходимости создавать индивидуальные потоковые операторы, их можно сделать обобщенными. Проблема с friend-декларацией операторов решается вводом вспомогательных функции getFromStream(), putToStream(). Они будут декларированы как friend для сериализуемых структур.

template<typename T>
QDataStream& getFromStream(QDataStream& s, T& t)
{
    if (s.atEnd())
        return s;
    quint8 size;
    s >> size;
    RawVector rv {int(size)};
    for (quint8 i = 0; i < size; ++i)
    {
        QByteArray ba {serialize::readByteArray(s)};
        rv[i] = std::move(ba);
    }
    t.fromRaw(rv);
    return s;
}

template<typename T>
QDataStream& putToStream(QDataStream& s, const T& t)
{
    const RawVector rv = t.toRaw();
    if (rv.size() > 255)
    {
        log_error << "For qbinary serialize the limit of versions is exceeded (255)";
        prog_abort();
    }
    s << quint8(rv.size());
    for (const QByteArray& ba : rv)
        s << ba;
    return s;
}

template<typename T>
inline QDataStream& operator>> (QDataStream& s, T& p)
    {return bserial::getFromStream<T>(s, p);}

template<typename T> 
inline QDataStream& operator<< (QDataStream& s, const T& p)
    {return bserial::putToStream<T>(s, p);}

#define DECLARE_B_SERIALIZE_FRIENDS \
    template<typename T> \
    friend QDataStream& bserial::getFromStream(QDataStream&, T&); \
    template<typename T> \
    friend QDataStream& bserial::putToStream(QDataStream&, const T&); 

Для минимизации проблем с инстанцированием обобщенных потоковых операторов, желательно чтобы они находились в одном пространстве имен с сериализуемыми структурами (правило ADL-поиска). Для этой цели в PProto-библиотеке используется пространство pproto::data. В общем случае, механизм сериализации может использоваться вне контекста PProto-библиотеки. Поэтому жестко привязываться к пространству имен pproto::data не очень разумно. Теоретически, можно каждый раз "копипастить" реализацию потоковых операторов в целевое пространство имен, но гораздо проще для этой цели использовать макрос.

#define DEFINE_B_SERIALIZE_STREAM_OPERATORS \
    template<typename T> \
    inline QDataStream& operator>> (QDataStream& s, T& p) \
        {return bserial::getFromStream<T>(s, p);} \
    template<typename T> \
    inline QDataStream& operator<< (QDataStream& s, const T& p) \
        {return bserial::putToStream<T>(s, p);} 

Для PProto декларация потоковых операторов определена в хедер-файле bserialize_space.h

#pragma once
#include "serialize/qbinary.h"
namespace pproto {
namespace data {
DEFINE_B_SERIALIZE_STREAM_OPERATORS
} // namespace data
} // namespace pproto

Теперь осталось минимизировать объявление функций сериализации и объединить их с friend-декларациями.

#define DECLARE_B_SERIALIZE_FUNC \
    bserial::RawVector toRaw() const; \
    void fromRaw(const bserial::RawVector&); \
    DECLARE_B_SERIALIZE_FRIENDS

Итоговое определение пользовательских структур данных будет выглядеть следующим образом:

struct F1
{
    qint8   value1 = {0};
    quint32 value2 = {0};
    DECLARE_B_SERIALIZE_FUNC
};

struct F2
{
    QString value3;
    QVector<qint32> value4;
    DECLARE_B_SERIALIZE_FUNC
};

struct S
{
    F1 field1;
    QList<F2> field2;
    DECLARE_B_SERIALIZE_FUNC
};

Полный вариант деклараций можно посмотреть в модуле qbinary.

Наследование

При создании сложных (вложенных) структур данных я стараюсь использовать агрегирование, но время от времени возникают ситуации, когда наследование является более предпочтительным.

struct Base
{
    qint8   value1 = {0};
    quint32 value2 = {0};
    DECLARE_B_SERIALIZE_FUNC
};
bserial::RawVector Base::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << value1;
    stream << value2;
    B_SERIALIZE_RETURN
}
void Base::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> value1;
    stream >> value2;
    B_DESERIALIZE_END
}

struct Base2
{
    qint8   value3 = {0};
    quint32 value4 = {0};
    DECLARE_B_SERIALIZE_FUNC
};
bserial::RawVector Base2::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << value3;
    stream << value4;
    B_SERIALIZE_RETURN
}
void Base2::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> value3;
    stream >> value4;
    B_DESERIALIZE_END
}

struct Derived : Base, Base2
{
    QString value5;
    QUuidEx value6;
    DECLARE_B_SERIALIZE_FUNC
};
bserial::RawVector Derived::toRaw() const
{
    B_SERIALIZE_V1(stream)
    stream << B_BASE_CLASS(Base);
    stream << B_BASE_CLASS(Base2);
    stream << value5;
    stream << value6;
    B_SERIALIZE_RETURN
}
void Derived::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> B_BASE_CLASS(Base);
    stream >> B_BASE_CLASS(Base2);
    stream >> value5;
    stream >> value6;
    B_DESERIALIZE_END
}

Здесь хотелось бы отметить два момента:

  • версионирование в базовых классах и классе наследнике будет независимым;

  • для десереализации только базового класса потребуется вспомогательный класс-пустышка.

struct BaseDeserializeDummy : Base
{
    DECLARE_B_SERIALIZE_FUNC
};
// Не используется
// bserial::RawVector BaseDeserializeDummy::toRaw() const {}

void BaseDeserializeDummy::fromRaw(const bserial::RawVector& vect)
{
    B_DESERIALIZE_V1(vect, stream)
    stream >> B_BASE_CLASS(Base);
    B_DESERIALIZE_END
}

Примеры

Специально для этой статьи был создан демонстрационный проект. Он содержит примеры сериализации данных описанные выше. Проект зависит от библиотек SharedTools, PProtoCpp, RapidJson, Yaml-Cpp, они подключены как субмодули.
Проект создан с использованием QtCreator, сборочная система QBS. Сценарий для сборки примеров сериализации: pproto_demo_serialize.qbs

  • SDemo 01 - базовый пример бинарной сериализации;

  • SDemo 02 - пассивное версионирование;

  • SDemo 03 - избыточное версионирование;

  • SDemo 04 - агрегирование;

  • SDemo 05 - наследование.

WEB все равно настигнет...

Бинарный протокол это хорошо, но ровно до тех пор, пока не нужно взаимодействовать с внешними системами. WEB стал первой такой системой. Заказчик одного из проектов настаивал на web-интерфейсе для пользователей. Использовать для взаимодействия с web-частью REST-API совсем не хотелось, SOAP вызывал не самые теплые воспоминания, поэтому тоже не вариант. Собственный протокол позволял отправлять/получать данные асинхронно в режиме полного дуплекса. Помимо обработки пользовательских команд, это давало возможность работать с событийными сообщениями в реальном времени. Вкупе с WebSocket-технологией могло получиться интересное решение. Реализацию web-части выполняла внешняя команда. Разработчики оказались опытные, на предложение попробовать проприетарный протокол отреагировали достаточно спокойно. Единственно, о чем не смогли сразу договориться - формат сериализации данных. Компромиссным вариантом мог стать JSON. Отсмотрев несколько JSON-сериализаторов я остановился на RapidJson. В репозитории библиотеки есть пример сериализации/десериализации с использованием operator&, на его основе был разработан собственный механизм с поддержкой наиболее востребованных типов данных. Не углубляясь в детали реализации, ниже отмечу основные особенности механизма.

  1. Возможность добавлять новые поля в структуру данных без потери совместимости (пассивное версионирование, реализуется через опциональные поля (см. п.3)).

  2. Отсутствие механизма избыточного версионирования.

  3. Возможность задавать признак обязательного/опционального поля при десериализации. Если сериализованные данные не содержат значения для обязательного поля, то такая десериализация завершится с ошибкой. Если же поле будет опциональным - десериализация пройдет успешно, а полю будет присвоено значение по умолчанию.

  4. Возможность наследования и агрегирования структур данных.

  5. Поддержка маппинга наименования полей.

Простейший пример JSON-сериализации:

struct A
{
    qint8   value1 = {0};
    qint32  value2;
    QString value3;
    QUuidEx value4;
    QVector<qint32> value5;
  
    J_SERIALIZE_BEGIN
        J_SERIALIZE_ITEM( value1 )
        J_SERIALIZE_ITEM( value2 )
        J_SERIALIZE_ITEM( value3 )
        J_SERIALIZE_ITEM( value4 )
        J_SERIALIZE_ITEM( value5 )
    J_SERIALIZE_END
};
// JSON представление
// {"value1":1,"value2":2,"value3":"Hello PProto","value4":"3db75294-445a-45bf-acba-5bc07fd208d5","value5":[1,2,3,4,5]}

Здесь нет необходимости кодировать функции сериализации/десериализации, как в бинарном механизме. Все уже включено в секцию макросов J_SERIALIZE_BEGIN-J_SERIALIZE_END. По сути, механизм сериализации сведен к декларативной форме.
Следующий пример демонстрирует пассивное версионирование (совместимость между структурами A и A_new сохраняется)

struct A
{
    qint8  value1 = {0};
    qint16 value2 = {0};
  
    J_SERIALIZE_BEGIN
        J_SERIALIZE_ITEM( value1 ) // Обязательное поле (такое поле уже нельзя исключить из структуры данных)
        J_SERIALIZE_ITEM( value2 ) // Обязательное поле
    J_SERIALIZE_END
};

struct A_new
{
    qint8  value1 = {0};
    qint16 value2 = {0};
    qint32 value3 = {0};
    qint64 value4 = {0};
  
    J_SERIALIZE_BEGIN
        J_SERIALIZE_ITEM( value1 ) // Обязательное поле
        J_SERIALIZE_ITEM( value2 ) // Обязательное поле
        J_SERIALIZE_OPT ( value3 ) // Опциональное поле
        J_SERIALIZE_OPT ( value4 ) // Опциональное поле
    J_SERIALIZE_END
};

Агрегирование:

struct F1
{
    qint8   value1 = {0};
    quint32 value2 = {0};
    
    J_SERIALIZE_BEGIN
        J_SERIALIZE_ITEM( value1 )
        J_SERIALIZE_ITEM( value2 )
    J_SERIALIZE_END
};

struct F2
{
    QString value3;
    QVector<qint32> value4;
    
    J_SERIALIZE_BEGIN
        J_SERIALIZE_ITEM( value3 )
        J_SERIALIZE_ITEM( value4 )
    J_SERIALIZE_END
};

struct S
{
    F1 field1;    
    QList<F2> field2;
  
    J_SERIALIZE_BEGIN
        J_SERIALIZE_ITEM( field1 )
        J_SERIALIZE_ITEM( field2 )
    J_SERIALIZE_END
};

Наследование:

struct Base
{
    qint8   value1 = {0};
    quint32 value2 = {0};
    quint64 value3 = {0};
  
    J_SERIALIZE_BASE_BEGIN
        J_SERIALIZE_ITEM( value1 )
        J_SERIALIZE_ITEM( value2 )
        J_SERIALIZE_ITEM( value3 )
    J_SERIALIZE_BASE_END
    J_SERIALIZE_BASE_ONE // Позволяет сериализовать базовый класс независимо от Derived
};

struct Derived : Base
{
    QString value4;
    QUuidEx value5;
    
    J_SERIALIZE_BEGIN
        J_SERIALIZE_BASE( BaseStruct )
        J_SERIALIZE_ITEM( value4 )
        J_SERIALIZE_ITEM( value5 )
    J_SERIALIZE_END
};

В отличии от бинарного механизма, для того чтобы десериализовать базовый класс не требуется никаких вспомогательных структур. Достаточно просто вызвать функцию десериализации для базового класса.

Base base;
base.fromJson(serializedDerivedData);

Маппинг полей:

struct B
{
    qint8   value1 = {1};
    qint32  value2 = {2};
  
    J_SERIALIZE_BEGIN
        J_SERIALIZE_MAP_ITEM( "value_name1", value1 )
        J_SERIALIZE_MAP_ITEM( "value_name2", value2 )
    J_SERIALIZE_END
};

// JSON представление
// {"value_name1":1,"value_name2":2}​

В демонстрационном проекте JSON-сериализация представлена следующими примерами:

  • SDemo 06 - базовый пример JSON сериализации;

  • SDemo 07 - пассивное версионирование;

  • SDemo 08 - агрегирование;

  • SDemo 09 - наследование;

  • SDemo 10 - маппинг наименований полей.

Никуда не деться от сравнения

Еще до начала работы над статьей было понимание того, что вопрос сравнения механизмов сериализации возникнет. Для сравнения выбран Protocol Buffers от Google. Это достаточно популярный и быстрый механизм сериализации. Тягаться с Protocol Buffers в плане компактного представления данных нет смысла, поэтому речь пойдет только о скорости. Это не полноценное тестирование, скорее оценочное, позволяющее приблизительно представить насколько разнятся скорости работы механизмов.
У Protocol Buffers есть базовый пример - адресная книга, на нем и проведем исследование. Книга содержит 100 тыс. адресов, в каждом адресе 5 телефонных номеров. В демо-проекте для целей тестирования созданы два подпроекта:

  • SDemo 11 - адресная книга с двойной сериализацией (qbinary, json);

  • SDemo 12 - адресная книга c protobuf-сериализацией.

Конфигурация тестового стенда:

  • OS: Ubuntu 20.04

  • Compiler: GCC 9.3.0 C++17

  • CPU: Intel Core i7 2700K, 4 ядра, 8 потоков (4.5GHz, разгон)

  • RAM: 32Gb (DDR3-1600, XMP 8-8-8-24-2N)

Intel Core i7 2700K 4.5GHz

qbinary

json

protobuf

Сериализация (мсек)

300

185

53

Десериализация (мсек)

370

334

135

Результат, прямо скажем, разгромный. Но в чем причина? Гипотеза первая: "QDataStream не очень быстрый". Такое предположение можно сделать если посмотреть на его реализацию. Гипотеза вторая: "Причина в механизме версионирования. Создание/уничтожение большого количества временных объектов негативно влияет на производительность". Первую гипотезу проверить просто: для этого нужно сериализовать адресную книгу без механизма версионирования, что и было сделано. Получились следующие результаты: сериализация - 70 мсек, десериализация - 140 мсек. Причина медленной работы qbinary стала очевидной.

В процессе создания тестовых примеров пришла мысль: "А сколько времени тратится на заполнение/вычитывание protobuf-структур?" Замеры оказались неожиданными: заполнение - 177 мсек, вычитывание - 218 мсек. Заполнение и вычитывание данных являются неотъемлемой частью процесса сериализации, поэтому указанное время можно добавить к времени "чистой" сериализации/десериализации. Для объективности будем так же учитывать время заполнения С++ структур, оно составляет 30 мсек, а вот время вычитывания будет равно нулю, так как десериализация qbinary и json выполняется сразу в плюсовые структуры без посредников.

Intel Core i7 2700K 4.5GHz

qbinary

json

protobuf

сериализация без версиониования

Сериализация (мсек)

330 (300+30)

215 (185+30)

230 (53+177)

100 (70+30)

Десериализация (мсек)

370 (370+0)

334 (334+0)

353 (135+218)

140 (140+0)

Необычно видеть текстовый сериализатор на первом месте (сериализация без версионирования идет вне конкурса). Для приверженцев json-сериализации прямо бальзам на душу! На самом деле, такой результат это всего лишь особенность используемого примера. Если изменить условия, например ввести в тест бинарные поля, json-показатели могут резко ухудшиться. Да и от аппаратной составляющей многое зависит. Для проверки одного из примеров мне потребовалось собрать демо-проект на слабеньком домашнем медиасервере (Intel Pentium J4205 1.50GHz). Соотношение сил оказалось иным.

Intel Pentium J4205 1.5-2.6GHz

qbinary

json

protobuf

сериализация без версиониования

Сериализация (мсек)

910 (850+60)

516 (456+60)

527 (160+367)

201 (141+60)

Десериализация (мсек)

1006 (1006+0)

790 (790+0)

664 (250+414)

286 (286+0)

В завершении, решил провести тесты на рабочем сервере AMD Ryzen Threadripper 2950X.

AMD Ryzen Threadripper 2950X 3.5-4.4GHz

qbinary

json

protobuf

сериализация без версиониования

Сериализация (мсек)

363 (336+27)

205 (178+27)

239 (73+166)

92 (65+27)

Десериализация (мсек)

419 (419+0)

342 (342+0)

322 (123+199)

136 (136+0)

Судя по замерам, нельзя однозначно сказать какой сериализатор быстрее: json или protobuf, примерно паритет. В плане объема, protobuf на четверть превосходит json (27.9Мб против 39.3МБ), но это не означает, что protobuf хорошо пакует данные. Если к сериализованным данным применить zip-сжатие, результат для обоих сериализаторов будет почти идентичным (~15Мб). Таким образом, в контексте экономии трафика, "чистый" protobuf почти в два раза проигрывает связке json+zip/protobuf+zip.

В завершение первой части

Есть небольшое разочарование от быстродействия qbinary, но тут уже нужно выбирать что важнее: скорость или версионирование. Для операций, критичных к скорости работы, будет разумным либо вовсе отказаться от механизма версионирования, либо реализовывать его не для каждого вложенного объекта. Стоит ли совсем пренебрегать версионированием в угоду скорости? Думаю, что - нет, по одной простой причине: механизм версионирования дает право на ошибку. Для меня это, прежде всего, минимизация возможных негативных последствий, связанных с неопределенностями первичного этапа разработки коммуникационного интерфейса.

Напомню, что в следующей части речь пойдет о концепции команд, о формате и способах обработки сообщений, а так же о механизмах их доставки.

Источник: https://habr.com/ru/post/647283/


Интересные статьи

Интересные статьи

В предыдущих статьях мы подробно разобрали работу сериалайзера на основе классов BaseSerializer и Serializer, и теперь мы можем перейти к классу-наследнику ModelSerializer. Класс модельных сериала...
Специально для тех, кто любит хорошее кино и качественный звук, мы подготовили подборку фильмов, в которых можно увидеть первоклассную Hi-Fi и High End аппаратуру. Самые интересные модели проигрывател...
В магазине Visual Studio есть множество различных расширений на все случаи жизни. Есть в сети различные их подборки, которые могут упростить жизнь в общих или конкретных случаях. Однако я, почему-то, ...
Продвижение свободы слова за счет изменения экономической и цифровой инфраструктуры интернета БУДУЩЕЕ СВОБОДЫ СЛОВА Серия статей, для переосмысления Первой поправки ...
Лет 10 назад мне нужна была какая-то система, чтобы вести блог о web-разработке и я использовал сильно хакнутый Drupal, который со временем стало невозможно обновить из-за груды косты...