JSON

Потоковый разбор больших JSON-файлов: альтернативы QJsonDocument для экономии памяти

1 просмотров

Введение

QJsonDocument::fromJson() загружает и разбирает весь JSON-документ в памяти целиком, что прекрасно работает для типичных ответов API размером в килобайты, но становится проблематичным для очень больших файлов — десятки и сотни мегабайт JSON, которые целиком в памяти могут занимать кратно больше из-за накладных расходов представления QJsonValue/QVariant. Для таких случаев нужен потоковый (SAX-подобный) разбор, обрабатывающий документ по частям без построения полного дерева в памяти. В статье рассмотрим подход к потоковому разбору больших JSON-файлов.

Концепция

Qt не предоставляет встроенного потокового JSON-парсера, аналогичного QXmlStreamReader для XML — для этой задачи обычно используются сторонние библиотеки, такие как RapidJSON с его SAX API, или ручная реализация инкрементального парсера для специфичного, заранее известного формата (например, построчного NDJSON — JSON Lines, где каждая строка файла — отдельный самостоятельный JSON-объект, что естественно поддаётся построчной потоковой обработке без сторонних библиотек).

Пример кода

// Вариант 1: построчный разбор NDJSON (JSON Lines) — без сторонних библиотек
#include <QFile>
#include <QTextStream>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>

void processLargeNdjsonFile(const QString &filePath)
{
    QFile file(filePath);
    if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
        qWarning() << "Не удалось открыть файл";
        return;
    }

    QTextStream stream(&file);
    qint64 processedLines = 0;
    double totalRevenue = 0.0;

    while (!stream.atEnd()) {
        const QString line = stream.readLine();
        if (line.trimmed().isEmpty()) continue;

        const QJsonDocument doc = QJsonDocument::fromJson(line.toUtf8());
        if (!doc.isObject()) continue;

        const QJsonObject obj = doc.object();
        totalRevenue += obj.value("amount").toDouble();
        processedLines++;

        // Каждая строка обрабатывается и тут же освобождается —
        // в памяти одновременно находится только одна запись, а не весь файл
    }

    qDebug() << "Обработано записей:" << processedLines;
    qDebug() << "Суммарная выручка:" << totalRevenue;
}
// Вариант 2: SAX-подобный разбор через RapidJSON для единого большого JSON-массива
#include <rapidjson/reader.h>
#include <rapidjson/filereadstream.h>
#include <cstdio>
#include <iostream>

struct OrderSumHandler : rapidjson::BaseReaderHandler<rapidjson::UTF8<>, OrderSumHandler>
{
    double totalAmount = 0.0;
    long long orderCount = 0;
    bool inAmountField = false;

    bool Key(const char *str, rapidjson::SizeType length, bool)
    {
        inAmountField = (std::string(str, length) == "amount");
        return true;
    }

    bool Double(double value)
    {
        if (inAmountField) {
            totalAmount += value;
            orderCount++;
        }
        return true;
    }

    bool Int(int value) { return Double(static_cast<double>(value)); }
};

void processLargeJsonArrayWithRapidJson(const char *filePath)
{
    FILE *file = fopen(filePath, "rb");
    if (!file) return;

    char readBuffer[65536];
    rapidjson::FileReadStream inputStream(file, readBuffer, sizeof(readBuffer));

    OrderSumHandler handler;
    rapidjson::Reader reader;
    reader.Parse(inputStream, handler);

    std::cout << "Обработано заказов: " << handler.orderCount << std::endl;
    std::cout << "Суммарная сумма: " << handler.totalAmount << std::endl;

    fclose(file);
}

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

Вариант с NDJSON читает файл построчно через QTextStream::readLine(), разбирая каждую строку как отдельный, самостоятельный JSON-документ через обычный QJsonDocument::fromJson() — поскольку каждая строка обрабатывается и сразу освобождается, в памяти в любой момент находится только текущая запись, а не весь файл целиком, что делает этот подход пригодным для файлов практически любого размера при условии, что формат файла действительно построчный.

Вариант с RapidJSON демонстрирует настоящий SAX-подход для единого большого JSON-документа (например, один огромный массив объектов, а не построчный формат) — OrderSumHandler реализует callback-методы (Key, Double, Int), вызываемые парсером по мере чтения потока без построения дерева объектов в памяти; в примере обрабатывается специфичная задача суммирования значений поля amount без хранения самих объектов целиком.

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

  • NDJSON-подход требует, чтобы формат файла действительно был построчным — попытка применить его к обычному единому JSON-документу (один большой объект или массив, отформатированный с произвольными разрывами строк) даст неверный результат, поскольку отдельные строки текста не будут являться самостоятельными валидными JSON-документами.
  • SAX-парсинг требует написания специфичного обработчика для каждой конкретной задачи, в отличие от универсального построения дерева QJsonDocument — обработчик жёстко завязан на конкретную структуру и цель обработки (как в примере — суммирование одного поля), и при изменении задачи (нужно посчитать что-то другое из того же файла) обработчик нужно переписывать, а не просто иначе обращаться к уже построенному дереву.
  • Отсутствие случайного доступа к данным при потоковой обработке. В отличие от полностью загруженного QJsonDocument, где можно обращаться к любому полю в произвольном порядке, потоковый разбор по своей природе последовательный — если задача требует многократного обращения к разным частям документа в произвольном порядке, потоковый подход потребует либо нескольких проходов по файлу, либо сохранения нужных промежуточных данных вручную во время единственного прохода.
  • Дополнительная зависимость от сторонней библиотеки (RapidJSON или аналог) для полноценного SAX API, поскольку Qt не предоставляет его «из коробки» — это увеличивает поверхность сборки проекта и требует отдельного внимания к версионной совместимости и лицензированию используемой библиотеки.