JSON

Конвертация между QJsonObject и пользовательскими C++ структурами (сериализация/десериализация)

Введение

После разбора JSON в QJsonDocument/QJsonObject обычно требуется преобразовать данные в типизированные C++ структуры или классы для удобной работы с ними в остальном коде — обращение к obj["field"].toString() повсюду в бизнес-логике быстро становится утомительным и подверженным ошибкам в именах полей. В статье разберём паттерны ручной сериализации/десериализации между QJsonObject и пользовательскими структурами, и обсудим, когда стоит рассмотреть автоматизацию через макросы или сторонние библиотеки.

Концепция

Базовый подход — определить для каждой структуры пару статических/свободных функций fromJson()/toJson(), явно перечисляющих соответствие между полями JSON и членами структуры. Это многословно, но абсолютно прозрачно и не требует дополнительных зависимостей. Для проектов с большим числом структур данных существуют библиотеки автоматической (де)сериализации на основе макросов или рефлексии (например, на основе Q_GADGET с QMetaObject, либо сторонние решения вроде QJsonSerializer), уменьшающие объём шаблонного кода ценой добавления зависимости и менее прозрачного поведения «под капотом».

Пример кода

// product.h
#pragma once
#include <QJsonObject>
#include <QJsonArray>
#include <QString>
#include <QVector>
#include <optional>

struct Product
{
    int id;
    QString name;
    double price;
    std::optional<QString> description; // поле может отсутствовать в JSON
    QVector<QString> tags;

    static Product fromJson(const QJsonObject &obj)
    {
        Product product;
        product.id = obj.value("id").toInt();
        product.name = obj.value("name").toString();
        product.price = obj.value("price").toDouble();

        if (obj.contains("description") && !obj.value("description").isNull()) {
            product.description = obj.value("description").toString();
        }

        const QJsonArray tagsArray = obj.value("tags").toArray();
        for (const QJsonValue &tag : tagsArray) {
            product.tags.append(tag.toString());
        }

        return product;
    }

    QJsonObject toJson() const
    {
        QJsonObject obj;
        obj["id"] = id;
        obj["name"] = name;
        obj["price"] = price;

        if (description.has_value()) {
            obj["description"] = description.value();
        }

        QJsonArray tagsArray;
        for (const QString &tag : tags) {
            tagsArray.append(tag);
        }
        obj["tags"] = tagsArray;

        return obj;
    }
};
// Использование: разбор массива продуктов из ответа API
#include "product.h"
#include <QJsonDocument>
#include <QDebug>

QVector<Product> parseProductList(const QByteArray &jsonData)
{
    QVector<Product> products;

    const QJsonDocument doc = QJsonDocument::fromJson(jsonData);
    if (!doc.isArray()) {
        qWarning() << "Ожидался JSON-массив";
        return products;
    }

    for (const QJsonValue &value : doc.array()) {
        if (value.isObject()) {
            products.append(Product::fromJson(value.toObject()));
        }
    }

    return products;
}

void demonstrateSerialization()
{
    const QByteArray input = R"([
        {"id": 1, "name": "Клавиатура", "price": 49.99, "tags": ["электроника", "офис"]},
        {"id": 2, "name": "Мышь", "price": 19.99, "description": "Беспроводная", "tags": []}
    ])";

    const auto products = parseProductList(input);
    for (const auto &product : products) {
        qDebug() << product.name << "—" << product.price;
    }

    QJsonDocument doc(products.first().toJson());
    qDebug() << doc.toJson(QJsonDocument::Indented);
}

Пояснения к коду

Product::fromJson() явно читает каждое поле из QJsonObject с использованием .value("key"), что безопасно возвращает «невалидное» значение по умолчанию для отсутствующих ключей вместо краша, в отличие от оператора [] доступа без проверки наличия ключа в некоторых других JSON-библиотеках. Поле description обёрнуто в std::optional, что явно отражает в самом типе C++ структуры тот факт, что это поле может отсутствовать в исходном JSON — этот случай отдельно проверяется через obj.contains().

toJson() выполняет обратное преобразование, симметрично собирая QJsonObject из полей структуры — наличие обоих методов в одном месте (рядом со структурой) облегчает поддержку контракта при изменении полей: добавление нового поля в структуру сразу видно как недостающее в обоих методах сериализации.

Подводные камни

  • .toInt()/.toString() и аналогичные методы QJsonValue не сообщают об ошибке, если тип значения не совпадает с ожидаемым — для отсутствующего или ошибочно типизированного значения они тихо возвращают значение по умолчанию (0, пустую строку), что может маскировать реальную проблему во входных данных, выдавая её за валидное, но «пустое» значение.
  • Дублирование списка полей между fromJson() и toJson() и самим объявлением структуры — три места, которые нужно обновлять синхронно при изменении состава полей; забытое обновление одного из методов после добавления нового поля в структуру — частый источник несимметричных багов сериализации.
  • Отсутствие валидации обязательности полей при десериализации. Метод fromJson() в примере не проверяет, что критичные поля (id, name) действительно присутствовали в исходном JSON — для входных данных, где это важно гарантировать, стоит явно проверять obj.contains() для каждого обязательного поля и сигнализировать об ошибке (например, через std::optional<Product> как возвращаемый тип или явное исключение) при их отсутствии.
  • Числа с плавающей точкой, представленные в JSON как целые без десятичной точки. QJsonValue::toDouble() корректно обрабатывает оба варианта, но при обратном round-trip (toJson()fromJson()) форматирование чисел может незначительно отличаться от исходного представления — это обычно не проблема для большинства приложений, но стоит иметь в виду при тестировании на точное совпадение строкового представления JSON.