QSerializer: решение для простой сериализации JSON/XML

    Привет, Хабр!

    Подумалось мне, что как-то несправедливо получается — у Java, С#, Go, Python и т.д. есть библиотеки для комфортной сериализации объектных данных в модные нынче JSON и XML, а в С++ то ли забыли, то ли не захотели, то ли и не особо надо, то ли сложно все это, а может и все вместе. Так что я решил это дело исправить.

    Все подробности, как обычно, под катом.

    image

    Предыстория


    Вновь я решил заняться очередным pet-проектом, суть которого заключалась в клиент-серверном обмене, сервером же должна была выступать любимая многими RaspberryPi. Помимо прочего, меня интересовал вопрос создания «точек сохранения» — так я мог бы максимально просто, в рамках прототипа, сохранить состояние объекта перед выходом и восстановиться при следующем запуске. По причине необоснованной неприязни к Python и моему весьма теплому отношению к Qt, я выбрал именно Qt & C++. Писать классы и спагетти-функции разбора JSON — то еще удовольствие, нужно было какое-то универсальное и в то же время легкое решение моей проблемы. «Надо разбираться», — сказал я себе.

    Для начала немного о терминах:
    Сериализация— процесс перевода какой-либо структуры данных в последовательность битов. Обратной к операции сериализации является операция десериализации (структуризации) — восстановление начального состояния структуры данных из битовой последовательности.
    В Go есть очень полезный «родной» пакет encoding/json, позволяющий производить полную сериализацию объекта методом Marshal и обратную структуризацию с помощью Unmarshal (из-за этой библиотеки у меня сначала сложилось не совсем верное понятие о маршалинге, но Desine sperare qui hic intras). Придерживаясь концепций этого пакета, я нашел еще одну библиотеку для Java — GSON, которая оказалась весьма приятным продуктом, использовать ее было сплошным удовольствием.

    Я размышлял над тем, что мне нравится в этих библиотеках, и пришел к выводу, что это их простота в использовании. Гибкий функционал и все в одном вызове, для сериализации в JSON достаточно было вызвать метод toJson и передать в него сериализуемый объект. Однако сам по себе C++ по умолчанию не обладает должными метаобъектными возможностями, для того чтобы предоставить достаточно информации о полях класса, как это сделано, например, в Java (ClassName.class).

    Под платформу Qt мне приглянулось только QJson, но все равно она не совсем укладывалась в мое понимание простоты использования, сформированное вышеупомянутыми библиотеками. Так появился проект, о котором здесь пойдет речь.

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

    Cтруктура проекта QSerializer


    Проект и примеры можно посмотреть на GitHub (ссылка на репозиторий). Там же расписана подробная инструкция по установке.

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

    Главная преследуемая цель этого проекта — сделать сериализацию с использованием user-frendly формата данных в С++ доступной и элементарной. Залог качественного развития и поддержания продукта — его архитектура. Я не исключаю, что в комментариях к этой статье могут появиться другие способы реализации, поэтому оставил немного «пространства для творчества». В случае изменения реализации можно либо добавить новую реализацию интерфейса PropertyKeeper, либо изменить методы фабрики так, что в функциях QSerializer ничего менять не придется.

    Декларация полей


    Одним из способов сбора метаобъектной информации в Qt является ее описание в метаобъектной системе самого Qt. Пожалуй, это самый простой способ. MOC сгенерит все необходимые метаданные на этапе компиляции. У описанного объекта можно будет вызвать метод metaObject, который вернет экземпляр класса QMetaObject, с которым нам и предстоит работать.

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

    Дальше макросом Q_PROPERTY описать члены класса. Мы будем называть описанное в Q_PROPERTY свойство property. QSerializer будет игнорировать property без флага USER равного true.

    Зачем флаг USER
    Это удобно в случаях работы, например, с QML. Зачастую не каждый член класса должен быть сериализован. Например, если использовать Q_PROPERTY для QML и для QSerializer можно получить лишние сериализуемые поля.

    class User : public QObject
    {
    Q_OBJECT
    // Define data members to be serialized
    Q_PROPERTY(QString name MEMBER name USER true)
    Q_PROPERTY(int age MEMBER age USER true)
    Q_PROPERTY(QString email MEMBER email USER true)
    Q_PROPERTY(std::vector<QString> phone MEMBER phone USER true)
    Q_PROPERTY(bool vacation MEMBER vacation USER true)
    public:
      // Make base constructor
      User() { }
     
      QString name;
      int age{0};
      QString email;
      bool vacation{false};
      std::vector<QString> phone; 
    };
    

    Для декларации нестандартных пользовательских типов в метаобъектной системе Qt я предлагаю использовать макро QS_REGISTER, который определен в qserializer.h. QS_REGISTER автоматизирует процесс регистрации вариаций типа. Однако вы можете использовать и классический способ регистрации типов через qRegisterMetaType<T>(). Для метаобъектной системы тип класса (T) и указатель на класс (T*) — абсолютно разные типы, они будут иметь разные идентификаторы в общем списке типов.

    #define QS_METATYPE(Type) qRegisterMetaType<Type>(#Type) ;
    #define QS_REGISTER(Type)       \
    QS_METATYPE(Type)               \
    QS_METATYPE(Type*)              \
    QS_METATYPE(std::vector<Type*>) \
    QS_METATYPE(std::vector<Type>)  \
    

    class User;
    void main()
    {
    // define user-type in Qt meta-object system
    QS_REGISTER(User)
    ...
    }

    Пространство имен QSerializer


    Как видно из UML диаграммы, QSerializer содержит ряд функций для сериализации и структуризации. Пространство имен концептуально отражает декларативную суть QSerializer. К заложенной функциональности можно получить доступ через имя QSerializer, без необходимости создания объекта в любом месте кода.

    На примере построения JSON на основе объекта вышеописанного класса User надо только вызвать метод QSerializer::toJson:

    User u;
    u.name = "Mike";
    u.age = 25;
    u.email = "[email protected]";
    u.phone.push_back("+12345678989");
    u.phone.push_back("+98765432121");
    u.vacation = true;
    QJsonObject json = QSerializer::toJson(&u);
    

    А вот и получившийся JSON:

    {
        "name": "Mike",
        "age": 25,
        "email": "[email protected]",
        "phone": [
            "+12345678989",
            "+98765432121"
        ],
        "vacation": true
    }

    Структурировать же объект можно двумя способами:

    • Если необходимо модифицировать объект
      User u;
      QJsonObject userJson;
      QSerializer::fromJson(&u, userJson);
    • Если необходимо получить новый объект
      QJsonObject userJson;
      User * u = QSerializer::fromJson<User>(userJson);

    Больше примеров и выходных данных можно увидеть в папке example.

    Хранители


    Для организации удобной записи и чтения декларированных свойств QSerializer использует классы-хранители (Keepers), каждый из них хранит указатель на объект (наследник QObject) и одну из его QMetaProperty. Сама по себе QMetaProperty не представляет особой ценности, по сути это лишь объект с описанием property класса, которая была задекларирована для MOC. Для чтения и записи нужен конкретный объект класса, где описано это свойство — это главное что нужно запомнить.

    Каждое сериализуемое поле в процессе сериализации передается в хранитель соответствующего типа. Хранители нужны, чтобы инкапсулировать функционал сериализации и структуризации для конкретной реализации под определенный тип описываемых данных. Я выделил 4 типа:

    • QMetaSimpleKeeper — хранитель свойств с примитивными типами данных
    • QMetaArrayKeeper — хранитель свойств с массивами примитивных данных
    • QMetaObjectKeeper — хранитель вложенных объектов
    • QMetaObjectArrayKeeper — хранитель массивов из вложенных объектов

    Поток данных

    В основе хранителей примитивных данных лежит преобразование информации из JSON/XML в QVariant и обратно, потому что QMetaProperty работает с QVariant по умолчанию.

    QMetaProperty prop;
    QObject * linkedObj;
    ...
    std::pair<QString, QJsonValue> QMetaSimpleKeeper::toJson()
    {
        QJsonValue result = QJsonValue::fromVariant(prop.read(linkedObj));
        return std::make_pair(QString(prop.name()), result);
    }
    
    void QMetaSimpleKeeper::fromJson(const QJsonValue &val)
    {
        prop.write(linkedObj, QVariant(val));
    }
    

    В основе хранителей объектов лежит трансфер информации из JSON/XML в серии других хранителей и обратно. Такие хранители работают со своим property как с отдельным объектом, который также может иметь своих хранителей, их задача состоит в сборе сериализованных данных из объекта property и структурировании объекта property по имеющимся данным.

    QMetaProperty prop;
    QObject * linkedObj;
    ...
    void QMetaObjectKeeper::fromJson(const QJsonValue &json)
    {
        ...
        QSerializer::fromJson(linkedObj, json.toObject());
    }
    
    std::pair<QString, QJsonValue> QMetaObjectKeeper::toJson()
    {
        QJsonObject result = QSerializer::toJson(linkedObj);;
        return std::make_pair(prop.name(),QJsonValue(result));
    }
    

    Хранители реализуют интерфейс PropertyKeeper, от которого унаследован базовый абстрактный класс хранителей. Это позволяет разбирать и составлять документы в формате XML или JSON последовательно сверху вниз, просто спускаясь по описанным хранимым свойствам и углубляясь по мере спуска во вложенные объекты, если таковые имеются в описанных propertyes, при этом не вдаваясь в детали реализации.

    Интерфейс PropertyKeeper
    class PropertyKeeper
    {
    public:
        virtual ~PropertyKeeper() = default;
        virtual std::pair<QString, QJsonValue> toJson() = 0;
        virtual void fromJson(const QJsonValue&) = 0;
        virtual std::pair<QString, QDomNode> toXml() = 0;
        virtual void fromXml(const QDomNode &) = 0;
    };
    

    Фабрика хранителей


    Так как все хранители реализуют один интерфейс, то все реализации скрываются за удобной ширмой, а набор этих реализаций предоставляется фабрикой KeepersFactory. У переданного в фабрику объекта можно получить список всех задекларированных propertyes через его QMetaObject, на основе которых определяется тип хранителя.

    Реализация фабрики KeepersFactory
        const std::vector<int> simple_t =
        {
            qMetaTypeId<int>(),
            qMetaTypeId<bool>(),
            qMetaTypeId<double>(),
            qMetaTypeId<QString>(),
        };
    
        const std::vector<int> array_of_simple_t =
        {
            qMetaTypeId<std::vector<int>>(),
            qMetaTypeId<std::vector<bool>>(),
            qMetaTypeId<std::vector<double>>(),
            qMetaTypeId<std::vector<QString>>(),
        };
    ...
    PropertyKeeper *KeepersFactory::getMetaKeeper(QObject *obj, QMetaProperty prop)
    {
        int t_id = QMetaType::type(prop.typeName());
        if(std::find(simple_t.begin(), simple_t.end(), t_id) != simple_t.end())
            return new QMetaSimpleKeeper(obj,prop);
        else if (std::find(array_of_simple_t.begin(),array_of_simple_t.end(), t_id) != array_of_simple_t.end())
        {
            if( t_id == qMetaTypeId<std::vector<int>>())
                return new QMetaArrayKeeper<int>(obj, prop);
    
            else if(t_id == qMetaTypeId<std::vector<QString>>())
                return new QMetaArrayKeeper<QString>(obj, prop);
    
            else if(t_id == qMetaTypeId<std::vector<double>>())
                return new QMetaArrayKeeper<double>(obj, prop);
    
            else if(t_id == qMetaTypeId<std::vector<bool>>())
                return new QMetaArrayKeeper<bool>(obj, prop);
        }
        else
        {
            QObject * castobj = qvariant_cast<QObject *>(prop.read(obj));
            if(castobj)
                return new QMetaObjectKeeper(castobj,prop);
            else if (QString(prop.typeName()).contains("std::vector<"))
            {
                QString t = QString(prop.typeName()).remove("std::vector<").remove(">");
                int idOfElement = QMetaType::type(t.toStdString().c_str());
                if(QMetaType::typeFlags(idOfElement).testFlag(QMetaType::PointerToQObject))
                    return new QMetaObjectArrayKeeper(obj, prop);
            }
        }
        throw QSException(UnsupportedPropertyType);
    }
    
    std::vector<PropertyKeeper *> KeepersFactory::getMetaKeepers(QObject *obj)
    {
        std::vector<PropertyKeeper*> keepers;
        for(int i = 0; i < obj->metaObject()->propertyCount(); i++)
        {
            if(obj->metaObject()->property(i).isUser(obj))
                keepers.push_back(getMetaKeeper(obj, obj->metaObject()->property(i)));
        }
        return keepers;
    }
    ...
    


    Ключевой особенностью фабрики хранителей является возможность предоставления полной серии хранителей для объекта, а расширить список поддерживаемых примитивных типов можно отредактировав константные коллекции с идентификаторами типов. Каждая серия хранителей представляет собой своеобразную карту по propertyes для объекта. При разрушении объекта KeepersFactory — память, выделенная под предоставленные ею серии хранителей, высвобождается.

    Ограничения и поведение

    Ситуация Поведение
    Попытка сериализации объекта, тип которого не унаследован от QObject Ошибка компиляции
    Незадекларированный тип при попытке сериализации/стркутуризации Исключение QSException::UnsupportedPropertyType
    Попытка сериализации/структуризации объекта с примитивным типом отличающимся от описанных в коллекциях simple_t и array_of_simple_t. Исключение QSException::UnsupportedPropertyType. Используйте стандартно закрепленные типы, а если очень нужно — можно добавить нужный вам примитив, но никаких гарантий
    В JSON/XML есть лишние поля Лишние поля игнорируются
    В объекте есть propertyes, которых нет в JSON/XML Лишние propertyes игнорируются. Если структуризация сопровождается созданием нового объекта — проигнорированные propertyes будут равны дефолтным значениям или задающимся в конструкторе по умолчанию
    Несоответствие типа описанных данных поля в JSON и property объекта Исключение QSException

    В заключение


    На мой взгляд, проект получился стоящим, потому эта статья и написана. Для себя я сделал вывод, что универсальных решений не бывает, всегда приходится чем-то жертвовать. Разрабатывая гибкую, с точки зрения использования, функциональность, вы убиваете простоту, и наоборот.

    Я не призываю вас использовать QSerializer, моей целью служит скорее собственное развитие как программиста. Разумеется я преследую и цель помочь кому-то, но в первую очередь — просто получение удовольствия. Будьте позитивными)

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 9

      +2
      Не хватает самого главного — сравнения производительности (дело оно не благодарное, но нужное).
        0
        Соглашусь, обязательно сделаю.
        0

        Здорово! Полезно! Спасибо!

          0
          4lenodevka Попробую с вами подискутировать:
          Вы описываете механизм конвертации в JSON объектов с примитивными типами данных, но как быть со вложенными объектами? В вашем случае, если я конечно же правильно понял идею, придется на каждый класс писать свою реализацию своего виртуального оператора, что меня смущает. Моя идея заключается в единоразовом описании полей в Q_PROPERTY и последующей работой с пространством имен.
          Насчет Enum и хардкода вектора я полностью с вами согласен, насчет вектора вообще ужасное решение.

          А вот насчет выделения памяти надо разбираться, хранимые указатели на целевой QObject занимают всего по 8 байт (на x64), объект QMetaProperty 32 байта, действительно расход большой, 40 байт на хранитель, но эта память освобождается сразу же после разрушения фабрики, которая предоставила в пользование объекты keepers. (т.е. суммарно при сериализации объекта с 5ю полями, на время сериализации будет выделено 200 байт, не кисло). Стоит ли сериализация такого расхода, пусть и кратковременного — решать вам, вообще серализация довольно затратный по памяти процесс.
          Как я сказал в конце — приходится чем-то жертвовать, если описывать реализацию оператора сериализации в классе, вы приобретаете в гибкости, но теряете в простоте использования. Более того, вам понадобится такой же оператор и для XML и для бинарного формата(который, я надеюсь, скоро появится в QSerializer), в итоге тут уже попахивает вынесением такой функциональности в отдельный класс (меня привлекает концепция миниатюрных классов, описанная в «Clean Code» Боба Мартина, и, на мой взгляд, стремление к простоте — вовсе не грех).

          Я учту ваши замечания, считаю такой опыт полезным, поэтому спасибо за ваше время.
            0

            По поводу списков согласен, моё решение тоже костыль, наверное, бОльший в вашем случае. Я не придумал, как (де)сериализовать списки как обычный Q_PROPERTY без проверок на тип внутри (де)сериализатора (то же касается вложенных сложных объектов), особенно если это список наследников QObject и их надо пробрасывать в qml именно как список. Хочется чего-то, что не потребует изменения кода сериализатора для каждого нового типа. Обычно всё выливается в вид QList<QObject*> с неприятными кастами на каждом шагу (это всё для проброса в qml) либо в QQmlListProperty, где много лишнего кода в реализации (или макросы, но большие проекты компилируются медленнее из большого кол-во кода в header-е).


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

            Да, правильно поняли, у вас эту проблему решают кастомный кипер.


            Скажите, а при необходимости использования get/set из c++ в реальном коде вы используете MEMBER в Q_PROPERTY или какие-то макросы, которорые делают Q_PROPERTY/signal/getter/setter/{private,protected} поле? Честно говоря, с MEMBER из вашей статьи познакомился, интересно.


            Я у себя использую макросы вида PROPERTY(type, name) и PROPERTY_DEFAULT(type, name, default), которые разворачиваются во всё вышеописанное + type m_ ## property = default во втором случае (что тоже неприятно радует временем компиляции, когда в проекте тысячи таких PROPERTY, зато избавляет от ошибок).


            Ещё интересно, как вы возвращаете из getter-ов (если они есть) вложенные наследники QObject: как указатель, что, по-моему, плохая идея, либо как копию/ссылку на объект. Во втором случае в Qt есть два подхода: возвращение копии с последующим вызовом setter-а и возвращение ссылки, которую можно дергать иерархически и на любом уровне менять объект. По моему актуальный вопрос при работе с qml.


            По поводу использования памяти у вас всё хорошо подытожено в заключении, остаётся только сказать, что есть примитивные классы (например, какой-нибудь vector3d), которых много и скаждым можно работать в qml. Тогда оверхед QObject-a в 16 байт выигрывает перед 40 байтами с дополнительными выделениями памяти.


            Кстати, оба подхода позволяют работать не только с json. Нашёл очень удобным для себя оператор QDebug operator<<(QDebug d, const Object &o) с проходом по полям, вдруг пригодится.


            Остаётся только пожелать, чтобы в Qt6 завезли хорошую годную рефлексию на QObject-based property и их списки. А вашу библиотеку обязательно попробую.

            0

            Хранить по объекту на property попахивает оверхедом, достаточно итерироваться по property и писать сразу в json, keeper-ы не нужны. Так быстрее и память не занимает.


            Заголовок спойлера
            BaseClass::operator const QJsonObject() const
            {
                QJsonObject result;
            
                // use BaseClass static offset, not virtual
                for(int i = staticMetaObject.propertyOffset(); i < metaObject()->propertyCount(); i++)
                {
                    auto key = metaObject()->property(i).name();
                    auto type = metaObject()->property(i).userType();
                    auto value = metaObject()->property(i).read(this);
            
                    if(value.isNull())
                    {
            //            qDebug() << .err..
                        continue;
                    }
            
                    auto converted = value.convert(type);
            
                    if(!converted)
                    {
            //            qDebug() << .err..
                    }
            
                    result[key] = QJsonValue::fromVariant(value);
                }
            
                return result;
            }

            Наследуясь от такого класса любой объект получает (виртуальный) оператор и можно писать QJsonObject json = myObject и регистрировать типы в массивах не надо. Остаётся только добавить отличие USER свойств и вложенных объектов. Хардкодить std::vector в регистрации тоже как-то некрасиво. Я себе сделал SerializableVector класс с похожей логикой, лучше ничего не придумал). QVariant(val), кстати, не работает с enum-ами (которые зарегистрированы в Q_ENUM/Q_ENUM_NS).

              0

              ответил вам выше, добавлю еще, что вместо передачи QMetaProperty в хранитель можно передавать ее индекс в списке propertyes. Так можно оставить по 12 байт на хранитель вместо 40, как вариант.

              +2

              Можно посмотреть еще в сторону Q_GADGET, что б сериализуемые объекты не были такими тяжёлыми с багажом от QObject.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое