КДПВ: логирование в Qt
Как должно выглядеть логирование в Qt

Каждый Qt-разработчик начинает знакомство с фреймворком с магической строчки qDebug() << "Hello World". Но задумывались ли вы, что происходит внутри этого вызова? Как Qt обрабатывает логи, какие есть ограничения, и главное — как это можно расширить под свои нужды?

В этой статье я разберу внутреннее устройство системы логирования Qt, покажу её сильные и слабые стороны, а затем представлю свою библиотеку QtLogger — надстройку, которая превращает базовый механизм в полноценную систему логирования корпоративного уровня.

Если вам не интересно вникать во внутренности qDebug(), но интересно узнать как решить его проблемы, то сразу переходите к разделу QtLogger — расширяем возможности.

Как работает логирование в Qt

В Qt есть пять макросов для логирования: qDebug(), qInfo(), qWarning(), qCritical() и qFatal(). Все они определены в <QtGlobal> и возвращают временный объект QDebug. Вот как выглядит определение qDebug() в исходниках Qt:

#define qDebug QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, \
                              QT_MESSAGELOG_FUNC).debug

Макросы __FILE__, __LINE__ и __func__ захватывают информацию о месте вызова. QMessageLogger создаёт контекст сообщения и передаёт его дальше, а метод debug() возвращает объект QDebug, который и делает всю работу.

Когда вы пишете qDebug() << "text", происходит следующее: создаётся QMessageLogger с информацией о файле и строке, он возвращает объект QDebug, тот накапливает данные через перегруженные операторы <<, и наконец в деструкторе отправляет всё в глобальный message handler.

QDebug — это по сути обёртка над QTextStream, которая накапливает данные во внутренний буфер. Вот упрощённая структура:

class QDebug {
public:
    QDebug(QtMsgType type, const QMessageLogContext &context)
        : stream(new Stream(type, context)) 
    {}
    
    // Копирующий конструктор увеличивает счётчик ссылок
    QDebug(const QDebug &other) : stream(other.stream) {
        ++stream->ref;
    }
    
    // Деструктор — ключевой момент!
    ~QDebug() {
        if (--stream->ref == 0) {
            qt_message_output(stream->type, stream->context, stream->buffer);
            delete stream;
        }
    }
    
    // Операторы возвращают ссылку на себя — это позволяет строить цепочки
    QDebug &operator<<(const QString &s) {
        stream->ts << s;
        return *this;
    }
    
private:
    struct Stream {
        QAtomicInt ref = 1;
        QTextStream ts;
        QString buffer;
        QtMsgType type;
        QMessageLogContext context;
    };
    Stream *stream;
};

Ключевой момент — оператор << возвращает ссылку QDebug&, а не новый объект. Благодаря этому работает цепочка вызовов:

qDebug() << "User:" << userName << "Age:" << age;

// Раскрывается в:
QDebug temp = qDebug();      // ref = 1, buffer = ""
temp << "User:";             // buffer = "User:"
temp << userName;            // buffer = "User: John"
temp << "Age:";              // buffer = "User: John Age:"
temp << age;                 // buffer = "User: John Age: 25"
// Конец выражения — деструктор отправляет сообщение в handler

Qt определяет операторы для множества типов — примитивных (bool, int, double), Qt-классов (QString, QByteArray, QDateTime) и даже контейнеров через шаблоны. По умолчанию QDebug добавляет пробелы между элементами и кавычки вокруг строк. Это можно отключить через nospace() и noquote().

Каждое сообщение сопровождается структурой QMessageLogContext:

struct QMessageLogContext {
    const char *file;      // Имя файла
    int line;              // Номер строки
    const char *function;  // Имя функции
    const char *category;  // Категория логирования
};

Обратите внимание: file, function и category — это const char*, а не QString. Это осознанное решение для производительности. Строковые литералы вроде __FILE__ хранятся в секции .rodata исполняемого файла и существуют всё время работы программы. При копировании const char* копируется только указатель (8 байт), тогда как для QString потребовалась бы аллокация в куче и конвертация из UTF-8 в UTF-16. При тысячах сообщений в секунду разница критична.

А вот message передаётся как QString, потому что формируется динамически через operator<< и может содержать что угодно.

Важный нюанс: в release-сборках file, line и function могут быть пустыми — Qt отключает их для оптимизации. Чтобы включить, нужно определить QT_MESSAGELOGCONTEXT до включения заголовков.

Из-за const char* возникает сложность: если нужно сохранить контекст (например, для асинхронного логирования), указатели могут стать невалидными. Как я решаю это в QtLogger — расскажу ниже.

Message Handler и форматирование

Ключевой механизм расширения — функция qInstallMessageHandler():

typedef void (*QtMessageHandler)(QtMsgType, const QMessageLogContext &, const QString &);
QtMessageHandler qInstallMessageHandler(QtMessageHandler handler);

По умолчанию Qt выводит сообщения в stderr (или в logcat на Android, os_log на macOS/iOS). Вы можете установить свой handler:

void myMessageHandler(QtMsgType type, const QMessageLogContext &context, 
                      const QString &msg)
{
    fprintf(stderr, "[%s] %s\n", context.category, msg.toLocal8Bit().constData());
}

int main(int argc, char *argv[])
{
    qInstallMessageHandler(myMessageHandler);
    // ...
}

Qt поддерживает настройку формата через переменную окружения QT_MESSAGE_PATTERN или функцию qSetMessagePattern(). Доступны плейсхолдеры: %{message}, %{type}, %{file}, %{line}, %{function}, %{category}, %{time}, %{threadid} и условные блоки %{if-debug}...%{endif}.

Начиная с Qt 5.2 появилась система категорий:

Q_LOGGING_CATEGORY(lcNetwork, "app.network")

qCDebug(lcNetwork) << "Connecting to server";

Фильтрация настраивается через QT_LOGGING_RULES или QLoggingCategory::setFilterRules(). Чуть ниже расскажу об этом подробнее.

Механизм отключения логирования

Qt предоставляет несколько способов отключить логирование — на этапе компиляции и во время выполнения.

Компиляционные макросы — самый радикальный способ. Если определить QT_NO_DEBUG_OUTPUT до включения заголовков Qt, макрос qDebug() превращается в заглушку:

#define QT_NO_DEBUG_OUTPUT
#include <QDebug>

qDebug() << expensiveFunction();  // Не компилируется вообще!

В исходниках Qt это реализовано примерно так:

#ifdef QT_NO_DEBUG_OUTPUT
#define qDebug QT_NO_QDEBUG_MACRO
#define QT_NO_QDEBUG_MACRO while (false) QMessageLogger().noDebug
#endif

Конструкция while (false) гарантирует, что код после qDebug() никогда не выполнится, а компилятор полностью удалит его как dead code. Аналогично работают QT_NO_INFO_OUTPUT и QT_NO_WARNING_OUTPUT.

Runtime-фильтрация через QLoggingCategory — более гибкий подход. Каждая категория хранит флаги для каждого уровня логирования:

class QLoggingCategory {
    // ...
    bool isDebugEnabled() const { return m_debugEnabled; }
    bool isInfoEnabled() const { return m_infoEnabled; }
    bool isWarningEnabled() const { return m_warningEnabled; }
    bool isCriticalEnabled() const { return m_criticalEnabled; }
private:
    bool m_debugEnabled;
    bool m_infoEnabled;
    // ...
};

Когда вы вызываете qCDebug(lcNetwork), макрос сначала проверяет lcNetwork().isDebugEnabled(). Если категория отключена, создание QDebug и форматирование пропускаются:

#define qCDebug(category, ...) \
    for (bool qt_category_enabled = category().isDebugEnabled(); qt_category_enabled; qt_category_enabled = false) \
        QMessageLogger(...).debug(category, __VA_ARGS__)

Хитрость с for вместо if — это приём для избежания проблем с else в макросах. Если isDebugEnabled() возвращает false, тело цикла не выполняется вообще.

Правила фильтрации задаются через переменную окружения QT_LOGGING_RULES или программно:

// Отключить debug для всех категорий, начинающихся с "app."
QLoggingCategory::setFilterRules("app.*.debug=false");

// Или через переменную окружения:
// export QT_LOGGING_RULES="app.*.debug=false;network.*=true"

Формат правил: <категория>[.<тип>]=<true|false>, где тип — это debug, info, warning или critical. Поддерживаются wildcard-паттерны (*). Правила применяются по порядку, последнее совпадение выигрывает.

Важный момент: для категорий проверка isDebugEnabled() происходит до форматирования сообщения. А вот для обычного qDebug() (без категории) runtime-отключения нет — сообщение всегда форматируется и передаётся в handler. Поэтому для production-кода рекомендуется использовать категории.

Нюансы производительности и ограничения

Есть несколько моментов, о которых стоит знать. Во-первых, каждый qDebug() << ... создаёт временные QString с аллокациями и конкатенациями — в горячих участках кода это заметно.

Во-вторых, хотя для категорий проверка isDebugEnabled() происходит до создания QDebug, аргументы операторов << вычисляются в любом случае:

qCDebug(lcNetwork) << expensiveToString(data); // expensiveToString() вызовется!

Это особенность C++: аргументы функции вычисляются до её вызова. Для оптимизации нужно явно проверять категорию: if (lcNetwork().isDebugEnabled()) { ... }.

В-третьих, стандартный handler использует mutex для потокобезопасности. При интенсивном логировании из нескольких потоков это становится узким местом. И в-четвёртых, запись происходит синхронно — медленный диск может заблокировать ваше приложение.

Главные ограничения встроенной системы:

  • Только один handler — нельзя отправить логи одновременно в файл, консоль и по сети

  • Нет ротации файлов — приходится писать самому или использовать внешние инструменты

  • Нет асинхронной записи — логирование блокирует вызывающий поток

  • Нет фильтрации по содержимому — только по категориям и уровням

  • Нет подавления дубликатов — одинаковые сообщения будут повторяться

  • Нет сжатия старых логов — управление дисковым пространством на вас

QtLogger — расширяем возможности

Столкнувшись с этими ограничениями в реальных проектах, я создал QtLogger — библиотеку, которая использует тот же механизм qInstallMessageHandler(), но предоставляет полноценную систему логирования с пайплайнами, фильтрами, форматтерами и множественными выходами.

Главный принцип — zero code changes. QtLogger работает с существующими qDebug(), qInfo(), qWarning(). Вам не нужно переписывать код или учить новый API:

#include "qtlogger.h"

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);
    
    gQtLogger.configure();  // Одна строка — и всё работает!
    
    qDebug() << "It just works!";
    
    return app.exec();
}

Архитектура

В основе лежит концепция пайплайна — цепочки обработчиков, через которую проходит каждое сообщение:

Все обработчики наследуются от Handler и реализуют метод process(LogMessage &lmsg), который возвращает true (продолжить) или false (прервать цепочку).

AttrHandler добавляет атрибуты к сообщению — например, порядковый номер или информацию о хосте. Filter решает, пропустить сообщение или нет — по уровню, категории, регулярному выражению или для подавления дубликатов. Formatter преобразует сообщение в строку — по шаблону, в JSON или в красивый цветной формат для консоли. Sink отправляет результат в точку назначения — консоль, файл, HTTP-endpoint, syslog, Android logcat или macOS os_log.

В отличие от стандартного QMessageLogContext, класс LogMessage содержит расширенную информацию:

class LogMessage {
public:
    // Стандартные поля уже присутствующие в Qt
    QtMsgType type() const;
    QMessageLogContext context() const;
    QString message() const;
    
    // Дополнительные поля вводимые библиоткеой QtLogger
    QDateTime time() const;       // Время создания
    quint64 threadId() const;     // ID потока
    
    QString formattedMessage() const;
    void setFormattedMessage(const QString &);
    
    // Пользовательские атрибуты
    QVariant attribute(const QString &name) const;
    void setAttribute(const QString &name, const QVariant &value);
    
private:
    const QDateTime m_time = QDateTime::currentDateTime();
    const quintptr m_qthreadptr = 
        reinterpret_cast<quintptr>(QThread::currentThreadId());
};

Время и ID потока захватываются в момент создания, а не записи — это важно для асинхронного логирования.

Помните проблему с const char* указателями? В LogMessage я решаю её копированием строк в QByteArray:

class LogMessage {
    const QByteArray m_file;
    const QByteArray m_function;
    const QByteArray m_category;
    const QMessageLogContext m_context;
    
public:
    LogMessage(const LogMessage &other)
        : m_file(other.m_context.file),
          m_function(other.m_context.function),
          m_category(other.m_context.category),
          m_context(m_file.constData(),
                    other.m_context.line,
                    m_function.constData(),
                    m_category.constData())
    {}
};

Каждая копия владеет своими строками, и указатели всегда валидны.

Конфигурация

QtLogger использует Fluent API для конфигурации:

gQtLogger
    .moveToOwnThread()              // Асинхронное логирование
    .addSeqNumber()                 // Добавить номер сообщения
    .pipeline()
        .filterLevel(QtWarningMsg) // Только warnings и выше
        .filterDuplicate()         // Подавлять дубликаты
        .formatPretty(true)        // Красивый формат в цвете
        .sendToStdErr()            // В консоль
    .end()
    .pipeline()
        .format("%{time} [%{category}] %{type}: %{message}")
        .sendToFile("app.log", 1024*1024, 5,       // Храним до 5 файлов по 1 Мб
                    RotatingFileSink::Compression) // И старые файлы архивируем
    .end();

Каждый пайплайн независим — сообщение, отфильтрованное в одном, всё равно попадёт в другие.

Асинхронность

По умолчанию логирование синхронное и защищено мьютексом. При вызове moveToOwnThread() создаётся фоновый поток:

template<typename BaseHandler>
class OwnThreadHandler : public BaseHandler {
public:
    OwnThreadHandler &moveToOwnThread() {
        m_thread = new QThread();
        m_worker = new Worker(this);
        m_worker->moveToThread(m_thread);
        m_thread->start();
        return *this;
    }
    
    bool process(LogMessage &lmsg) override {
        if (m_worker) {
            QCoreApplication::postEvent(m_worker, new LogEvent(lmsg));
        } else {
            BaseHandler::process(lmsg);
        }
        return true;
    }
};

Сообщения передаются через postEvent() как QEvent. Worker в фоновом потоке обрабатывает их последовательно. При завершении приложения ждём обработки всех накопленных сообщений.

Форматирование

PatternFormatter парсит шаблон в набор токенов при создании, а затем эффективно собирает строку. Каждый токен умеет оценивать свою длину, что позволяет зарезервировать память один раз:

QString format(const LogMessage &lmsg) {
    size_t estimatedLength = 0;
    for (const auto &token : m_tokens) {
        estimatedLength += token->estimatedLength();
    }
    
    QString result;
    result.reserve(estimatedLength);  // Одна аллокация!
    
    for (const auto &token : m_tokens) {
        token->appendToString(lmsg, result);
    }
    return result;
}

Поддерживаемые плейсхолдеры: %{message}, %{type}, %{line}, %{file}, %{function}, %{category}, %{time}, %{time yyyy-MM-dd}, %{time process} (время от старта), %{threadid}, %{shortfile}, %{func} (сокращённые версии), %{if-debug}...%{endif}, %{attr_name} (пользовательские атрибуты), спецификаторы формата %{category:>20} для выравнивания.

PrettyFormatter — специализированный форматтер для консоли с ANSI-цветами, сжатым представлением потоков (T0, T1, T2 вместо длинных ID) и автоматическим выравниванием категорий.

Ротация логов

RotatingFileSink поддерживает ротацию по размеру, ежедневную ротацию, ротацию при старте и gzip-сжатие старых файлов:

void rotate() {
    file()->close();
    
    const auto rotatedFileName = generateRotatedFileName(rotationDate, nextIndex);
    QFile::rename(currentFileName, rotatedFileName);
    
    if (m_compression) {
        compressFile(rotatedFileName);
    }
    
    removeOldFiles();
    file()->open(QIODevice::WriteOnly | QIODevice::Append);
}

Фильтры и выходы

Фильтры: filterLevel(QtWarningMsg) — по уровню, filterCategory("app.network.debug=false") — по категориям, filter("^(?!.*password:).*$") — по регулярному выражению, filterDuplicate() — подавление дубликатов.

Выходы: StdOutSink/StdErrSink — консоль с цветами, FileSink/RotatingFileSink — файлы, HttpSink — HTTP-endpoint для Seq/Logstash/Loki, SyslogSink — syslog, SdJournalSink — systemd journal, AndroidLogSink — logcat, OsLogSink — macOS/iOS os_log, WinDebugSink — Windows debugger, SignalSink — Qt-сигнал для GUI.

Можно сконфигурировать из INI-файла:

[logger]
filter_rules = "*.debug=false"
message_pattern = "%{time} [%{category}] %{type}: %{message}"
path = "app.log"
max_file_size = 1048576
max_file_count = 5
rotate_on_startup = true
compress_old_files = true
async = true
gQtLogger.configureFromIniFile("config.ini");

QtLogger можно использовать как header-only (просто скопируйте qtlogger.h), CMake-библиотеку или через qmake.

Почему вам стоит использовать QtLogger в своих проектах?

Zero code changes — работает с существующими qDebug(), не нужно переписывать код. Гибкая архитектура — пайплайны позволяют направить разные сообщения в разные места. Асинхронность — логирование не блокирует основной поток. Ротация и сжатие — встроенная ротация с gzip, не нужен logrotate. Кроссплатформенность — Linux, Windows, macOS, iOS, Android с платформо-специфичными выходами. Широкая поддержка — Qt 5.9 — Qt 6.10. Подавление дубликатов и regex-фильтрация — защита от спама и sensitive data. JSON-формат — готовый вывод для ELK, Seq, Loki. Fluent API — читаемая конфигурация.

Заключение

Система логирования Qt — мощный, но базовый инструмент. Для production-приложений часто не хватает множественных выходов, асинхронности, ротации файлов и гибкой фильтрации. QtLogger решает эти проблемы, оставаясь полностью совместимым с существующим кодом.

Проект распространяется под лицензией MIT и доступен на GitHub: github.com/yamixst/qtlogger

P.S. Если вы используете Qt и вам не хватает возможностей стандартного логирования — попробуйте QtLogger. Интеграция занимает одну строку. Буду рад звёздочкам на GitHub и pull request'ам!

Полезные ссылки:

Буду рад вопросам и предложениям в комментариях!