Привет, Хабр!
Подумалось мне, что как-то несправедливо получается — у 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, моей целью служит скорее собственное развитие как программиста. Разумеется я преследую и цель помочь кому-то, но в первую очередь — просто получение удовольствия. Будьте позитивными)