Прошло несколько месяцев с тех пор, как я здесь рассказал о своем проекте Qt-based библиотеки для сериализации данных из объектного вида в JSON/XML и обратно.

И как бы я не гордился выстроенной архитектурой, надо признать — реализация получилась, прямо скажем, спорной.

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



QSerializer умер


У QSerializer были недостатки, решение которых зачастую становилось еще большим недостатком, вот несколько из них:

  • Очень дорого (сериализация, содержание хранителей свойств на куче, контроль времени жизни хранителей и т.д.)
  • Работа только с QObject-based классами
  • Вложенные «сложные» объекты и их коллекции должны так же являться QObject-based
  • Невозможность дополнять коллекции при десериализации
  • Только теоретически бесконечная вкладываемость
  • Отсутствие возможности работать со значимыми типами «сложных» объектов, по причине запрета на копирование у QObject
  • Необходимость обязательной регистрации типов в метаобъектной системе Qt
  • Типичные «библиотечные» проблемы вроде проблем с линковкой и переносимостью между платформами

Помимо прочего, хотелось иметь возможность у любого объекта сериализоваться «здесь и сейчас», когда для этого приходилось пользоваться огромной обвязкой методов в пространстве имен QSerializer.

Да здравствует QSerializer!


QSerializer не был полноценным. Необходимо было придумать решение, при котором бы пользователь не зависел от QObject, была возможность работать со значимыми типами и подешевле.

В комментарии к предыдущей статье, пользователь microla заметил, что можно подумать над применением Q_GADGET.

Достоинства Q_GADGET:

  • Не накладывает ограничений на копирование
  • Имеет статический экземпляр QMetaObject для доступа к properties

Оперевшись на Q_GADGET, пришлось пересмотреть подход к способам создания JSON и XML на основе задекларированных полей класса. Проблема «дороговизны» проявлялась в первую очередь из за:

  • Большого размера класса-хранителя (по меньшей мере 40 байт)
  • Выделения кучи под новые сущности хранителей для каждого property и контроль их TTL

Для снижения стоимости я сформулировал следующее требование:
Наличие в каждом сериализуемом объекте методов-проводников для сериализации/десериализации всех properties класса и наличие для каждого property методов чтения и записи значений с использованием отведенного для этого property формата

Макросы


Обойти строгую типизацию С++, усложняющую автоматическую сериализацию, не так просто, и предыдущий опыт это показал. Макросы же могут послужить прекрасным подспорьем для решения такой проблемы (практически вся метаобъектная система Qt построена на макросах), ведь с помощью макросов можно сделать кодогенерацию методов и properties.

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

Лирическое отступление о макросах
Макрос — это просто набор лексем, и думать о макросах мы должны именно с точки зрения текста, который после разворачивания должен стать «компилируемым» текстом (токеном). Поэтому макросы можно рассматривать как правила для составления блоков текста.

Декларация класса


Сейчас в QSerializer предусмотрено 2 пути для объявления класса как сериализуемого: наследование от класса QSerializer или использование макроса генерации кода QS_CLASS.

Перво наперво необходимо определить макрос Q_GADGET в теле класса, это дает доступ к staticMetaObject, в нем будут храниться сгенерированные макросами properties.

Наследование от QSerializer позволит привести множество сериализуемых объектов к одному типу и сериализовать их скопом.

Класс QSerializer содержит 4 метода-проводника, которые позволят разбирать properties объекта и один виртуальный метод для получения экземпляра QMetaObject:

QJsonValue toJson() const
void fromJson(const QJsonValue &)
QDomNode toXml() const
void fromXml(const QDomNode &)
virtual const QMetaObject * metaObject() const

Q_GADGET не имеет всей метаобъектной обвязки, которую предоставляет Q_OBJECT.

Внутри QSerializer экземпляр staticMetaObject будет представлять класс QSerializer, но никак не производный от него, поэтому при создании QSerializer-based класса необходимо переопределить метод metaObject. Можно добавить макрос QS_SERIALIZER в тело класса и он переопределит метод metaObject за Вас.

А еще использование staticMetaObject вместо хранения экземпляра QMetaObject в каждом объекте экономит 40 байт от размера класса, ну вообще красота!

Если наследоваться по каким-либо причинам не хочется — можно определить в теле сериализуемого класса макрос QS_CLASS, он сгенерирует все необходимые методы вместо наследования их от QSerializer.

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


Обособленно, в JSON и XML есть 4 вида сериализуемых данных, без которых сериализация в эти форматы не будет полноценной. В таблице приведены виды данных и соответствующие им макросы как способ описания:
Вид данных Описание Макрос
поле обычное поле примитивного типа (различные числа, строки, флаги) QS_FIELD
коллекция набор значений примитивных типов данных QS_COLLECTION
объект сложная структура из полей или других сложных структур QS_OBJECT
коллекция объектов набор из сложных структур данных одного типа QS_COLLECTION_OBJECTS

Будем считать, что код, который генерируют эти макросы, называется описанием, а макросы, которые его генерируют — описательными.

Принцип генерации описания один — для конкретного поля сгенерировать JSON и XML property и определить методы записи/чтения значений.

Разберем генерацию описания JSON на примере поля примитивного типа данных:

/* Create JSON property and methods for primitive type field*/
#define QS_JSON_FIELD(type, name)                                                           
    Q_PROPERTY(QJsonValue name READ get_json_##name WRITE set_json_##name)                  
    private:                                                                                
        QJsonValue get_json_##name() const {                                                
            QJsonValue val = QJsonValue::fromVariant(QVariant(name));                       
            return val;                                                                     
        }                                                                                   
        void set_json_##name(const QJsonValue & varname){                                   
            name = varname.toVariant().value<type>();                                       
        }   
...
int digit;
QS_JSON_FIELD(int, digit)  

Для поля int digit будет сгенерирован property digit с типом QJsonValue и определены приватные методы записи и чтения — get_json_digit и set_json_digit, они то и станут проводниками для сериализации/десериализации поля digit с использованием JSON.

Как это происходит?
В макросе под псевдонимом name лежит слово digit, два символа решетки ('##') конкатенируют слово digit с предстоящей последовательностью символов — так создаются методы.

Под псевдонимом type скрывается слово int. Представьте, что вместо type в сгенерированном коде будет написано int и все станет на свои места. Это дает возможность в макросе привести значение лежащее в QVariant к типу int и использовать этот макрос по отношению к любым другим типам.

А вот и генерация описания JSON для сложной структуры:

/* Generate JSON-property and methods for some custom class */
/* Custom type must be provide methods fromJson and toJson */
#define QS_JSON_OBJECT(type, name)
    Q_PROPERTY(QJsonValue name READ get_json_##name WRITE set_json_##name)
    private:
    QJsonValue get_json_##name() const {
        QJsonObject val = name.toJson();
        return QJsonValue(val);
    }
    void set_json_##name(const QJsonValue & varname) {
        if(!varname.isObject())
        return;
        name.fromJson(varname);
    } 
...
SomeClass object;
QS_JSON_OBJECT(SomeClass, object)

Сложные объекты — это набор вложенных properties, которые для внешнего класса будут работать как одно «большое» property, потому что такие объекты также будут иметь методы-проводники. Все что для этого нужно сделать — в методах чтения и записи у сложных структур вызывать соответствующий метод-проводник.

Создание класса


Таким образом, мы имеем достаточно простую инфраструктуру для создания сериализуемого класса.

Так, например, можно сделать класс сериализуемым с помощью наследования от QSerializer:

class SerializableClass : public QSerializer {
Q_GADGET
QS_SERIALIZER
QS_FIELD(int, digit)
QS_COLLECTION(QList, QString, strings)
};

Или так, используя макрос QS_CLASS:

class SerializableClass {
Q_GADGET
QS_CLASS
QS_FIELD(int, digit)
QS_COLLECTION(QList, QString, strings)
};

Пример сериализации в JSON
Добавим еще один класс для полноты картины:

class CustomType : public QSerializer {
Q_GADGET
QS_SERIALIZER
QS_FIELD(int, someInteger)
QS_FIELD(QString, someString)
};

class SerializableClass : public QSerializer {
Q_GADGET
QS_SERIALIZER
QS_FIELD(int, digit)
QS_COLLECTION(QList, QString, strings)
QS_OBJECT(CustomType, someObject)
QS_COLLECTION_OBJECTS(QVector, CustomType, objects)
};

Создание объекта, его наполнение и сериализация:

SerializableClass serializable;
serializable.someObject.someString = "ObjectString";
serializable.someObject.someInteger = 99999;
for(int i = 0; i < 3; i++) {
    serializable.digit = i;
    serializable.strings.append(QString("list of strings with index %1").arg(i));
    serializable.objects.append(serializable.someObject);
}
QJsonObject json = serializable.toJson();

Получившийся JSON:

{
    "digit": 2,
    "objects": [
        {
            "someInteger": 99999,
            "someString": "ObjectString"
        },
        {
            "someInteger": 99999,
            "someString": "ObjectString"
        },
        {
            "someInteger": 99999,
            "someString": "ObjectString"
        }
    ],
    "someObject": {
        "someInteger": 99999,
        "someString": "ObjectString"
    },
    "strings": [
        "list of strings with index 0",
        "list of strings with index 1",
        "list of strings with index 2"
    ]
}

Как видите — ничего сверхъестественного, все перечисленное эквивалентно и для XML формата, нужно только заменить метод toJson на toXml.

Более подробные примеры Вы найдете в папке example.

Ограничения


Одиночные поля

Пользовательские или примитивные типы должны предоставлять конструктор по умолчанию.

Коллекции

Класс коллекции должен быть шаблонным и предоставлять методы clear, at, size и append. Вы можете использовать собственные коллекции при соблюдении условий. Коллекции Qt, удовлетворяющие этим условиям: QVector, QStack, QList, QQueue.

Версии Qt

Минимальная версия Qt 5.5.0
Минимальная протестированная версия Qt 5.9.0
Максимальная протестированная версия Qt 5.15.0
NOTE: вы можете поучавствовать в тестировании и проверить QSerializer на более ранних версиях Qt

Итог


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

Спрашивая себя: «Стоил ли он потраченных на него усилий?», я отвечу: «Да, стоил». Я уже успел опробовать его на своих боевых проектах и результат меня порадовал.

Links
GitHub: ссылка
Последний релиз: v1.1
Предыдущая статья: QSerializer: решение для простой сериализации JSON/XML

Future list

  • Существенное удешевление (можно сделать еще дешевле)
  • Компактность
  • Работа со значимыми типами
  • Элементарное описание сериализуемых данных
  • Поддержка любых шаблонных коллекций, предоставляющих методы clear, at, size и append. Даже собственных
  • Полная изменяемость коллекций при десериализации
  • Поддержка всех популярных примитивных типов
  • Поддержка любого пользовательского типа, описанного с использованием QSerializer
  • Отсутствие необходимости регистрировать пользовательские типы