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

Вновь я решил заняться очередным pet-проектом, суть которого заключалась в клиент-серверном обмене, сервером же должна была выступать любимая многими RaspberryPi. Помимо прочего, меня интересовал вопрос создания «точек сохранения» — так я мог бы максимально просто, в рамках прототипа, сохранить состояние объекта перед выходом и восстановиться при следующем запуске. По причине необоснованной неприязни к Python и моему весьма теплому отношению к Qt, я выбрал именно Qt & C++. Писать классы и спагетти-функции разбора JSON — то еще удовольствие, нужно было какое-то универсальное и в то же время легкое решение моей проблемы. «Надо разбираться», — сказал я себе.
Для начала немного о терминах:
Я размышлял над тем, что мне нравится в этих библиотеках, и пришел к выводу, что это их простота в использовании. Гибкий функционал и все в одном вызове, для сериализации в JSON достаточно было вызвать метод toJson и передать в него сериализуемый объект. Однако сам по себе C++ по умолчанию не обладает должными метаобъектными возможностями, для того чтобы предоставить достаточно информации о полях класса, как это сделано, например, в Java (ClassName.class).
Под платформу Qt мне приглянулось только QJson, но все равно она не совсем укладывалась в мое понимание простоты использования, сформированное вышеупомянутыми библиотеками. Так появился проект, о котором здесь пойдет речь.
Небольшой дисклеймер: подобные механизмы не решат за вас задачу интерпретации данных. Все, что вы можете от них получить — это конвертацию данных в более удобный для вас вид.
NOTE: Вышла вторая статья о QSerializer
Проект и примеры можно посмотреть на GitHub (ссылка на репозиторий). Там же расписана подробная инструкция по установке.
Предвкушая архитектурное самоубийство, сделаю оговорку, что это не финальная версия. Работа будет вестись и дальше, невзирая на брошенные камни, но сделав поправку на пожелания.

Главная преследуемая цель этого проекта — сделать сериализацию с использованием user-frendly формата данных в С++ доступной и элементарной. Залог качественного развития и поддержания продукта — его архитектура. Я не исключаю, что в комментариях к этой статье могут появиться другие способы реализации, поэтому оставил немного «пространства для творчества». В случае изменения реализации можно либо добавить новую реализацию интерфейса PropertyKeeper, либо изменить методы фабрики так, что в функциях QSerializer ничего менять не придется.
Одним из способов сбора метаобъектной информации в Qt является ее описание в метаобъектной системе самого Qt. Пожалуй, это самый простой способ. MOC сгенерит все необходимые метаданные на этапе компиляции. У описанного объекта можно будет вызвать метод metaObject, который вернет экземпляр класса QMetaObject, с которым нам и предстоит работать.
Для объявления подлежащих сериализации полей нужно унаследовать класс от QObject и включить в него макрос Q_OBJECT, дабы дать понять MOC о квалификации типа класса как базового от QObject.
Дальше макросом Q_PROPERTY описать члены класса. Мы будем называть описанное в Q_PROPERTY свойство property. QSerializer будет игнорировать property без флага USER равного true.
Для декларации нестандартных пользовательских типов в метаобъектной системе Qt я предлагаю использовать макро QS_REGISTER, который определен в qserializer.h. QS_REGISTER автоматизирует процесс регистрации вариаций типа. Однако вы можете использовать и классический способ регистрации типов через qRegisterMetaType<T>(). Для метаобъектной системы тип класса (T) и указатель на класс (T*) — абсолютно разные типы, они будут иметь разные идентификаторы в общем списке типов.
Как видно из UML диаграммы, QSerializer содержит ряд функций для сериализации и структуризации. Пространство имен концептуально отражает декларативную суть QSerializer. К заложенной функциональности можно получить доступ через имя QSerializer, без необходимости создания объекта в любом месте кода.
На примере построения JSON на основе объекта вышеописанного класса User надо только вызвать метод QSerializer::toJson:
А вот и получившийся JSON:
Структурировать же объект можно двумя способами:
Больше примеров и выходных данных можно увидеть в папке example.
Для организации удобной записи и чтения декларированных свойств QSerializer использует классы-хранители (Keepers), каждый из них хранит указатель на объект (наследник QObject) и одну из его QMetaProperty. Сама по себе QMetaProperty не представляет особой ценности, по сути это лишь объект с описанием property класса, которая была задекларирована для MOC. Для чтения и записи нужен конкретный объект класса, где описано это свойство — это главное что нужно запомнить.
Каждое сериализуемое поле в процессе сериализации передается в хранитель соответствующего типа. Хранители нужны, чтобы инкапсулировать функционал сериализации и структуризации для конкретной реализации под определенный тип описываемых данных. Я выделил 4 типа:

В основе хранителей примитивных данных лежит преобразование информации из JSON/XML в QVariant и обратно, потому что QMetaProperty работает с QVariant по умолчанию.
В основе хранителей объектов лежит трансфер информации из JSON/XML в серии других хранителей и обратно. Такие хранители работают со своим property как с отдельным объектом, который также может иметь своих хранителей, их задача состоит в сборе сериализованных данных из объекта property и структурировании объекта property по имеющимся данным.
Хранители реализуют интерфейс PropertyKeeper, от которого унаследован базовый абстрактный класс хранителей. Это позволяет разбирать и составлять документы в формате XML или JSON последовательно сверху вниз, просто спускаясь по описанным хранимым свойствам и углубляясь по мере спуска во вложенные объекты, если таковые имеются в описанных propertyes, при этом не вдаваясь в детали реализации.
Так как все хранители реализуют один интерфейс, то все реализации скрываются за удобной ширмой, а набор этих реализаций предоставляется фабрикой KeepersFactory. У переданного в фабрику объекта можно получить список всех задекларированных propertyes через его QMetaObject, на основе которых определяется тип хранителя.
Ключевой особенностью фабрики хранителей является возможность предоставления полной серии хранителей для объекта, а расширить список поддерживаемых примитивных типов можно отредактировав константные коллекции с идентификаторами типов. Каждая серия хранителей представляет собой своеобразную карту по propertyes для объекта. При разрушении объекта KeepersFactory — память, выделенная под предоставленные ею серии хранителей, высвобождается.
На мой взгляд, проект получился стоящим, потому эта статья и написана. Для себя я сделал вывод, что универсальных решений не бывает, всегда приходится чем-то жертвовать. Разрабатывая гибкую, с точки зрения использования, функциональность, вы убиваете простоту, и наоборот.
Я не призываю вас использовать QSerializer, моей целью служит скорее собственное развитие как программиста. Разумеется я преследую и цель помочь кому-то, но в первую очередь — просто получение удовольствия. Будьте позитивными)
Подумалось мне, что как-то несправедливо получается — у Java, С#, Go, Python и т.д. есть библиотеки для комфортной сериализации объектных данных в модные нынче JSON и XML, а в С++ то ли забыли, то ли не захотели, то ли и не особо надо, то ли сложно все это, а может и все вместе. Так что я решил это дело исправить.
Все подробности, как обычно, под катом.

Предыстория
Вновь я решил заняться очередным 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, но все равно она не совсем укладывалась в мое понимание простоты использования, сформированное вышеупомянутыми библиотеками. Так появился проект, о котором здесь пойдет речь.
Небольшой дисклеймер: подобные механизмы не решат за вас задачу интерпретации данных. Все, что вы можете от них получить — это конвертацию данных в более удобный для вас вид.
NOTE: Вышла вторая статья о QSerializer
Cтруктура проекта QSerializer
Проект и примеры можно посмотреть на GitHub (ссылка на репозиторий). Там же расписана подробная инструкция по установке.
Предвкушая архитектурное самоубийство, сделаю оговорку, что это не финальная версия. Работа будет вестись и дальше, невзирая на брошенные камни, но сделав поправку на пожелания.

Главная преследуемая цель этого проекта — сделать сериализацию с использованием 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 = "example@exmail.com"; u.phone.push_back("+12345678989"); u.phone.push_back("+98765432121"); u.vacation = true; QJsonObject json = QSerializer::toJson(&u);
А вот и получившийся JSON:
{ "name": "Mike", "age": 25, "email": "example@exmail.com", "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, моей целью служит скорее собственное развитие как программиста. Разумеется я преследую и цель помочь кому-то, но в первую очередь — просто получение удовольствия. Будьте позитивными)
