ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt宏日志系统设计与实现:从调用点定位到文件滚动

Qt宏日志系统设计与实现:从调用点定位到文件滚动

1. 为什么日志一定得用宏定义来做

1.1 普通日志函数取不到真实的调用点

很多 Qt 项目一开始都是这么过来的:为了排查问题,自己在代码里写了一个logMessage(const QString&)函数,内部用qDebug()打印,然后所有模块都调它。这个方案跑起来确实能看,但一旦程序发到客户机器上,问题就来了——日志里全是这一行:

logMessage: user login failed

你根本不知道是哪条分支、哪个函数、哪个文件打印出来的。想加文件行号?那就得在每个调用点手写__FILE__和__LINE__,白白写了十几次之后人就开始烦躁,而且总会漏掉。

解决这个问题的关键,是把“获取调用点”这件事交给预处理阶段去干。宏展开发生在编译之前,__FILE__、__LINE__、__func__这些预定义宏在调用点原地展开,写到日志里的就是真实的调用位置。普通函数做不到这一点,因为无论你从哪调用,函数内部拿到的__FILE__都指向这个日志函数所在的文件,行号也是固定的。

1.2 宏还能顺带解决变参传递和发布剥离

写日志的时候,我们通常希望这样自然一点:

LOG_INFO("user id = %d, status = %s", userId, statusText);

这里参数个数是不定的。用函数重载去适配所有参数组合不现实,用std::function又显得绕。宏配合可变参数__VA_ARGS__可以原样转发,不丢参数。

宏还有另一个普通函数替代不了的好处:编译期剥离。发布版本如果不想让低级别日志参与任何计算,可以在一处把宏定义成((void)0),所有调用点自动变成空语句。函数做不到这一点,函数代码还在,参数照样会被求值,额外开销也省不掉。

我常用的比喻是:宏就是贴在调用点上的一个摄像头,编译阶段就把位置信息一次性拍进去,后面谁想篡改都难;普通函数是让所有调用方去前台报道,前台却忘了登记你是哪个部门的。


2. 一套能直接抄的宏日志实现

2.1 整体设计的核心思路

我先说清楚这套东西的定位:它面向 Qt Widgets / Qt Quick 这类桌面客户端,也适用后台服务程序,目标是实现“三件套”——分级打印、调用点定位、文件输出。

设计上分成三层:

  • 第一层是枚举:LogDebug、LogInfo、LogWarn、LogError。数值越小越细。
  • 第二层是日志类Log:负责打开文件、格式化、加锁、滚动文件。
  • 第三层是宏:LOG_DEBUG、LOG_INFO、LOG_WARN、LOG_ERROR,统一调用Log::write(),同时注入文件行号。

这里有个经验:日志类不要做成复杂单例,用静态成员函数最简单。单例那一套生命周期管理在这个场景里纯属增加心智负担,静态函数初始化顺序也更好控制。真需要全局唯一的文件句柄,静态成员就够了。

2.2 核心实现代码

这是头文件:

#pragma once #include <QString> #include <QFile> #include <QMutex> #include <QDir> enum LogLevel { LogDebug = 0, LogInfo = 1, LogWarn = 2, LogError = 3, LogOff = 4 }; class Log { public: static void init(const QString &dirPath, const QString &baseName, LogLevel level = LogInfo); static void shutdown(); static void setOutputConsole(bool enabled); static bool isEnabled(LogLevel level); static void write(LogLevel level, const char *file, int line, const char *func, const QString &message); static void cleanOldFiles(int keepDays); private: static void ensureFileLocked(); static void rollByDayLocked(); static void rollBySizeLocked(); static QString levelName(LogLevel level); static QFile s_file; static QMutex s_mutex; static bool s_inited; static bool s_toConsole; static QString s_dirPath; static QString s_baseName; static QString s_currentFile; static QString s_currentDate; static LogLevel s_level; static qint64 s_maxBytes; };

实现文件:

#include "Log.h" #include <QDateTime> #include <QFileInfo> #include <QCoreApplication> #include <cstdio> QFile Log::s_file; QMutex Log::s_mutex; bool Log::s_inited = false; bool Log::s_toConsole = true; QString Log::s_dirPath = QStringLiteral("."); QString Log::s_baseName = QStringLiteral("app"); QString Log::s_currentFile = QStringLiteral("app.log"); QString Log::s_currentDate; LogLevel Log::s_level = LogInfo; qint64 Log::s_maxBytes = 50LL * 1024 * 1024; void Log::init(const QString &dirPath, const QString &baseName, LogLevel level) { QMutexLocker locker(&s_mutex); s_dirPath = dirPath.isEmpty() ? QStringLiteral(".") : dirPath; s_baseName = baseName.isEmpty() ? QStringLiteral("app") : baseName; s_level = level; QDir dir(s_dirPath); s_dirPath = dir.absolutePath(); if (!dir.exists()) { dir.mkpath(s_dirPath); } s_currentDate = QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")); s_currentFile = QStringLiteral("%1/%2_%3.log") .arg(s_dirPath, s_baseName, s_currentDate); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { const QString head = QStringLiteral("\n===== logger start %1 =====\n") .arg(QDateTime::currentDateTime() .toString(QStringLiteral("yyyy-MM-dd hh:mm:ss"))); s_file.write(head.toUtf8()); s_inited = true; } else { s_inited = false; std::fprintf(stderr, "[Log] open log file failed: %s\n", qPrintable(s_currentFile)); } } void Log::shutdown() { QMutexLocker locker(&s_mutex); if (s_file.isOpen()) { s_file.write("===== logger end =====\n"); s_file.flush(); s_file.close(); } s_inited = false; } void Log::setOutputConsole(bool enabled) { s_toConsole = enabled; } bool Log::isEnabled(LogLevel level) { return level >= s_level && level < LogOff; } QString Log::levelName(LogLevel level) { switch (level) { case LogDebug: return QStringLiteral("DEBUG"); case LogInfo: return QStringLiteral("INFO "); case LogWarn: return QStringLiteral("WARN "); case LogError: return QStringLiteral("ERROR"); default: return QStringLiteral("?????"); } } void Log::write(LogLevel level, const char *file, int line, const char *func, const QString &message) { if (!isEnabled(level)) { return; } QMutexLocker locker(&s_mutex); ensureFileLocked(); rollByDayLocked(); rollBySizeLocked(); QString sourceFile = QString::fromLocal8Bit(file); int slash = sourceFile.lastIndexOf(QLatin1Char('/')); if (slash < 0) { slash = sourceFile.lastIndexOf(QLatin1Char('\\')); } if (slash >= 0) { sourceFile = sourceFile.mid(slash + 1); } const QString timeText = QDateTime::currentDateTime() .toString(QStringLiteral("yyyy-MM-dd hh:mm:ss.zzz")); const QString levelText = levelName(level); const QString lineText = QStringLiteral("%1 [%2] %3:%4 %5 | %6\n") .arg(timeText) .arg(levelText) .arg(sourceFile) .arg(line) .arg(QString::fromLocal8Bit(func)) .arg(message); if (s_file.isOpen()) { s_file.write(lineText.toUtf8()); s_file.flush(); } if (s_toConsole) { std::fputs(lineText.toLocal8Bit().constData(), stderr); std::fflush(stderr); } } void Log::ensureFileLocked() { if (s_file.isOpen()) { return; } QDir dir(s_dirPath); if (!dir.exists()) { dir.mkpath(s_dirPath); } s_currentDate = QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")); s_currentFile = QStringLiteral("%1/%2_%3.log") .arg(s_dirPath, s_baseName, s_currentDate); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { s_inited = true; } } void Log::rollByDayLocked() { const QString date = QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")); if (date == s_currentDate) { return; } s_currentDate = date; s_file.close(); s_currentFile = QStringLiteral("%1/%2_%3.log") .arg(s_dirPath, s_baseName, s_currentDate); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { s_inited = true; } } void Log::rollBySizeLocked() { if (!s_file.isOpen() || s_file.size() < s_maxBytes) { return; } const QString stamp = QDateTime::currentDateTime() .toString(QStringLiteral("yyyyMMdd_HHmmss")); QString archived = QStringLiteral("%1/%2_%3_%4.log") .arg(s_dirPath, s_baseName, s_currentDate, stamp); int seq = 1; while (QFileInfo::exists(archived)) { archived = QStringLiteral("%1/%2_%3_%4_%5.log") .arg(s_dirPath, s_baseName, s_currentDate, stamp) .arg(seq); ++seq; } s_file.close(); QFile::rename(s_currentFile, archived); s_file.setFileName(s_currentFile); if (s_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { s_inited = true; } } void Log::cleanOldFiles(int keepDays) { QMutexLocker locker(&s_mutex); QDir dir(s_dirPath); QStringList filters; filters << QStringLiteral("%1_*.log").arg(s_baseName); const QFileInfoList fileList = dir.entryInfoList(filters, QDir::Files, QDir::Name); const QDateTime deadline = QDateTime::currentDateTime().addDays(-keepDays); for (const QFileInfo &info : fileList) { if (info.lastModified() < deadline) { QFile::remove(info.absoluteFilePath()); } } }

然后是在头文件末尾挂上的宏:

#define LOG_WRITE(level, ...) \ do { \ if (Log::isEnabled(level)) { \ Log::write((level), __FILE__, __LINE__, __func__, QString::asprintf(__VA_ARGS__)); \ } \ } while (0) #define LOG_DEBUG(...) LOG_WRITE(LogDebug, __VA_ARGS__) #define LOG_INFO(...) LOG_WRITE(LogInfo, __VA_ARGS__) #define LOG_WARN(...) LOG_WRITE(LogWarn, __VA_ARGS__) #define LOG_ERROR(...) LOG_WRITE(LogError, __VA_ARGS__)

2.3 使用方式和实际输出效果

项目启动时这样初始化一次:

Log::init(QStringLiteral("./logs"), QStringLiteral("client")); Log::setOutputConsole(true);

业务代码里直接调用:

LOG_INFO("user login success, id = %d", userId); LOG_ERROR("request timeout, retry = %d", retryCount); LOG_DEBUG("http response body = %s", body.toUtf8().constData());

输出文件内容大致长这样:

2025-01-01 10:20:30.123 [INFO ] mainwindow.cpp:124 MainWindow::onLogin | user login success, id = 10001 2025-01-01 10:20:31.456 [ERROR] network.cpp:87 NetworkClient::processResponse | request timeout, retry = 2

每一行都有毫秒时间戳、日志级别、源文件名、行号、函数名。排查客户反馈问题时,对着日志拉到出错前后几行,基本就能锁定业务路径。

这里有几个实操上的细节要提醒:

  • 宏定义中do { } while (0)不是摆设,它保证宏展开成一条完整语句,别人在if/else后面调用不会挂掉分支。
  • __func__在 MSVC 2015 之后、GCC/Clang 上都是标准支持的,够用。Q_FUNC_INFO返回的是完整签名,日志会很臃肿,我基本不用它。
  • QString::asprintf至少要传一个格式串,所以LOG_INFO()这种空参数写法编译不过。这不算缺点,反而是约束,逼着你把要打的内容写清楚。

3. 日志文件的滚动、清理和编码

3.1 按天滚动和按大小切分,怎么落地

日志文件如果只写一个app.log,用不了几天就会膨胀到几个 GB。宁可让它按日期拆开,方便按天检索,也方便删除历史日志。

上面代码里,文件名规则是base_yyyy-MM-dd.log。跨天的时候,write()内部检测到日期变化,关闭旧文件,打开新文件。这个滚动不是靠定时器做的,而是在日志写入路径里顺带检查。逻辑简单,也不会漏掉任何一次写入。

同一份文件还要防单日过大。比如某个客户机跑了一个异常任务,一天打了好几百兆日志。我的默认值是单文件 50 MB,超过就归档成base_2025-01-01_143523.log,原位置立即重建新文件。归档前先检查同名文件是否存在,避免同一秒内连续触发导致彼此覆盖。

3.2 历史日志的自动清理

日志只滚动不清理,最后磁盘一样会满。我给Log类加了一个清理接口,按文件修改时间删除指定天数之前的日志文件:

Log::cleanOldFiles(7);

这个接口在程序启动时调用一次就够了。实际项目里我会把“日志保留天数”做成配置文件里的一个可选参数,默认 7 天,客户环境特殊需求再改。

清理的时候要注意一点:不要用文件名排序来推断新旧,因为同一天的归档文件和当前文件混在一起时,名字排序并不严格等于时间顺序。用lastModified()判断最稳。

3.3 编码问题别再交学费了

日志文件最稳妥的编码是 UTF-8 无 BOM。Windows 上老版本记事本打开无 BOM 的 UTF-8 会乱码,但现代系统基本已经没这个问题。如果你服务的客户还在用旧记事本,可以在初始化时往文件头写三个字节\xEF\xBB\xBF,代价是 Linux 下看文件会多一个不可见字符。我这边默认不写 BOM,保持跨平台干净。

另外提醒一下 Qt 版本差异:Qt 5 里常见写法是QTextStream::setCodec(),Qt 6 直接把setCodec()移除了,改成了setEncoding(QStringConverter::Utf8)。为了避免版本迁移时的麻烦,我上面的实现干脆不用QTextStream写文件,全部用QFile::write(lineText.toUtf8())。这样 Qt 5 和 Qt 6 都能编,少踩一个坑。


4. 多线程写入、控制台输出和发布版剥离

4.1 加锁很简单,别把事情搞复杂

桌面客户端里日志往往来自多个线程:界面线程、网络线程、业务工作线程。QFile本身不是线程安全的,同一个文件句柄并发写一定会出问题。用QMutex包住文件写入是最直接的手段。

我甚至建议把锁的范围放大一点——从“打开文件、滚动检查、格式化、写入”整个过程都锁住。原因很简单:滚动逻辑要判断文件状态,格式化要用时间戳,这些操作交叉进行时如果线程各锁各的,会产生一堆竞态条件。日志系统追求的是正确性,不是纳秒级性能。

真正高频率写日志(每秒几千条)的时候,再考虑异步队列加写线程。对绝大多数 Qt 桌面应用,每个线程每秒写几十条日志已经算多了,加锁的代价可以忽略不计。你更需要担心的反而是日志写得太多把 UI 卡了——因为我上面的代码里write()里有一条s_file.flush()。日志量大的场景可以改成定时 flush,或者去掉逐条 flush,每 1 秒刷一次。

4.2 发布版把 Debug 日志彻底剥掉

运行时过滤只能减少写入,格式化和函数调用还是在发生。如果某个LOG_DEBUG出现在高频循环里,即使等级被过滤掉了,参数也已经被求值,性能损耗照旧。

我的做法是在Log.h里给LOG_DEBUG单独留一个编译开关:

#ifdef QT_NO_DEBUG # undef LOG_DEBUG # define LOG_DEBUG(...) ((void)0) #endif

也就是 Debug 构建完整输出,Release 构建把LOG_DEBUG编译成空操作,彻底消除所有 Debug 日志参数求值和函数调用。这个思路和 Qt 自带的QT_NO_DEBUG_OUTPUT一致,只是换成了自己的宏。

LOG_INFO、LOG_WARN、LOG_ERROR在 Release 下正常保留,但初始化级别可以设成LogWarn。这样客户机上的日志只记录警告和错误,既不丢关键信息,也不会疯长。

4.3 日志参数里不要放带副作用的表达式

因为发布版可能把整个LOG_DEBUG替换成((void)0),所以有一个隐藏得很深的坑:

LOG_DEBUG("count = %d", count++);

Debug 模式下count会自增;Release 模式下这一行被完全剥离,count++根本不会执行。同一个代码库,两种构建行为不一样,这种 bug 特别难排查。

给自己的约定是:日志宏参数只能用只读表达式,禁止++、赋值、函数调用等有副作用的内容。真有需要在日志里计算某种状态的,先在前面算好,再传变量进去。


5. qDebug 和自定义日志宏怎么配合

5.1 qInstallMessageHandler 的适用场景

很多项目已经到处是qDebug()了,要全部改成自定义宏,工作量不小。这时候可以考虑 Qt 自带的消息处理机制:

static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) { LogLevel level = LogDebug; switch (type) { case QtDebugMsg: level = LogDebug; break; case QtInfoMsg: level = LogInfo; break; case QtWarningMsg: level = LogWarn; break; case QtCriticalMsg: case QtFatalMsg: level = LogError; break; default: break; } QString src; int line = 0; if (context.file) { src = QString::fromLocal8Bit(context.file); line = context.line; } Log::write(level, src.toLocal8Bit().constData(), line, context.function ? context.function : "", msg); } // 在 main() 里安装一次 qInstallMessageHandler(messageHandler);

这样做的好处是第三方库、Qt 内部的警告也全都被统一收进日志文件。坏处是,QMessageLogContext里的file、line、function在部分发布构建下可能是空的,你拿不到可靠调用点。所以如果调用点信息是强需求,还得回到自定义宏。

5.2 我推荐的组合方式

我的建议是分情况:

  • 老项目、大量qDebug(),先上qInstallMessageHandler,立刻能拿到统一文件,改动最小。
  • 新项目、或者日志格式要求高度统一,直接用自定义宏,不依赖任何运行时消息机制。
  • 两者混用也完全可以:自定义宏写业务日志,同时qInstallMessageHandler兜底把漏网之鱼收集到一起。

需要注意的是,Log::write()内部不要再调用qDebug()之类的接口,否则会形成递归。文件和控制台输出自己用QFile和fputs完成就好。


6. 踩坑记录和一点实操建议

6.1 MSVC 与格式串的兼容性

QString::asprintf底层依赖 C 的vsnprintf系列,跨平台格式串兼容性并不完全一致。比如%lld在 MSVC 和 MinGW 下的表现有历史差异,%S(宽字符)在不同工具链下的语义也不一样。

我自己的经验是:日志格式串尽量只用基本的%d、%s、%f、%u;复杂拼接别硬塞进格式串,改成先拼QString,再通过%s传进去。宁可多写一行代码,也不要为一个百分号查半天文档。Qt 的类型安全接口QString::arg()其实是更好的拼接方式,只能在日志宏外面先用着,因为宏里无法对参数做类型模板推导。

6.2 流式日志宏的 if/else 悬挂坑

有些团队喜欢流式风格,把日志宏设计成:

#define LOG_INFO if (Log::isEnabled(LogInfo)) LogStream(LogInfo, __FILE__, __LINE__)

然后调用:

if (loginFailed) LOG_INFO << "login failed"; else retry();

展开之后,else会和宏里的if配对,retry()的逻辑就全乱掉了。有人用for循环包一层去绕过它,但那需要额外设计代理对象,可读性也下降。我这边干脆全部采用 printf 风格宏,配合do { } while (0),从根上回避这个坑。风格统一之后,团队协作也没人写错。

6.3 日志文件被占用、多进程互写

Windows 上经常遇到日志文件被编辑器、杀毒软件占用的情况,滚动改名失败是常态。我在代码里对rename失败没有做强处理,原因就是它不影响继续写当前文件,不会让程序崩溃。你只要记住:日志文件不是给人打开看的,是给程序读的。排查问题前先拷出到别的目录再打开,别把源文件锁住。

多进程场景更麻烦。两个实例同时写同一个日志文件,即使QFile以 Append 模式打开,日志行也可能交错,而且flush和滚动之间没有跨进程锁。最简单粗暴的做法是初始化时把进程号拼进文件名:

Log::init(logDir, QStringLiteral("client_%1") .arg(QCoreApplication::applicationPid()));

每个进程一个文件,谁的问题看谁的文件,干净利落。

6.4 记得把日志目录变成可配置参数

日志目录最怕写死在代码里。发布版程序在不同机器上工作目录不同,一旦没权限写当前目录,日志文件打不开,你连“为什么打不开”都看不到。我建议初始化路径从配置、命令行参数或标准目录中取得,比如QStandardPaths::writableLocation(QStandardPaths::AppDataLocation),再在下面建一个logs子目录。

如果配置漏了,默认落到当前目录也可以,但一定要打开失败时往stderr打一条提示。客户现场反馈“没有日志”,有很大概率不是程序没打日志,而是程序根本没地方写。尽早暴露这个问题,能省掉好几轮无效沟通。

我在实际项目里用这套宏日志跑过多个客户端,最大的感受是:日志系统的价值不在于代码写得多花哨,而在于你拿到日志文件的那一刻,能不能一眼锁定问题。宏定义日志把“谁在哪个文件的第几行说了什么”全部固化下来,排查问题时基本不用再靠猜。后续如果遇到更高频的日志需求,可以在现有基础上加队列和后台写线程,但核心的等级控制、调用点注入、文件滚动这套骨架不用变。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进