简介:这份资源面向使用 QT 框架进行 C++ 开发的初中级开发者,聚焦日志记录这一常见但易被忽视的工程环节,解决日志数据如何持久化保存、日志文件如何按条件自动切分的问题。压缩包共 6 个文件,约 6KB,以 cpp 与 h 源码为主体,配合 pro 工程文件与说明文档,构成一个可直接编译运行的日志模块示例,便于读者对照理解类设计与调用方式。目前已有 2383 人学习下载,说明该主题在实际项目中需求稳定。源码围绕日志路径设置、单文件大小上限、超限后自动新建带时间戳的日志文件等核心逻辑展开,并预留日志级别、时间戳等扩展思路,读者可据此快速搭建自定义日志系统,或将其嵌入现有 QT 工程,提升调试与问题排查效率。
1. 日志写到第 30 天,我才发现文件已经 2GB 了
做 Qt 客户端的朋友大概率都踩过这个坑:程序跑得好好的,某天客户反馈“软件越用越卡”,远程一看,日志文件已经涨到 2GB,记事本打开直接卡死,想查三天前的一次崩溃根本翻不到。更尴尬的是,日志全堆在一个app.log里,出问题时只能靠grep硬捞,捞出来的上下文还串了行。这个资源要解决的就是这件事——在 Qt C++ 工程里做一套“按条件自动切分”的日志落盘方案:按日期切、按大小切、按级别分文件,写满就换新文件,老文件按策略清理。它适合正在用 Qt 做桌面端或工控上位机、需要长期稳定运行且要留痕排查的开发者,尤其是那种“程序一跑就是几个月不重启”的场景。下面按我实际拆这套东西的顺序讲,从架构选型一路讲到参数怎么调、坑在哪。
2. 日志落盘的架构选型:为什么不用 qDebug 直接重定向
2.1 三种常见写法的取舍
在动手写切分逻辑之前,先得把“日志从哪来、往哪去”这条链路定下来。Qt 里落盘日志常见三种做法,选错了后面切分逻辑会写得很别扭。
第一种是qInstallMessageHandler装一个全局消息处理器,把qDebug/qInfo/qWarning全部接管,在 handler 里自己写文件。优点是业务代码零改动,原来怎么写qDebug()现在还怎么写;缺点是 handler 是全局单例,多线程下必须自己加锁,而且拿不到“这条日志属于哪个模块”这种结构化信息。
第二种是包一个自己的Logger类,业务侧显式调用Logger::info("mod", "msg")。灵活、能带模块名和线程 id,但要改业务代码,老项目迁移成本高。
第三种是引入第三方库(如 spdlog 的 Qt 封装)。功能全,但多一个依赖,工控环境里编译链一乱就头疼。
我一般会选第一种打底、第二种做增强:用qInstallMessageHandler接管全局输出保证不漏,同时在 handler 内部把QMessageLogContext里的file/line/function拿出来做结构化,切分逻辑全部收敛到一个LogWriter单例里。这样业务侧不用动,切分策略又能集中管理。下面这套代码就按这个思路走。
2.2 切分条件的三个维度
“根据条件自动创建日志文件”这句话,落到实现上其实是三个正交的维度,得先想清楚再写:
| 维度 | 触发条件 | 典型参数 | 适用场景 |
|---|---|---|---|
| 按时间 | 跨天/跨小时 | 日期格式yyyy-MM-dd | 排查“某天发生了什么” |
| 按大小 | 单文件超过阈值 | 10MB / 50MB | 防止单文件过大打不开 |
| 按级别 | 级别变化 | Debug/Info/Warn/Error 分文件 | 生产环境只留 Warn 以上 |
实际工程里通常是“时间 + 大小”组合:文件名带日期,同一天内超过大小阈值就加序号后缀。级别分文件一般只在调试期开,生产环境全量写一个流更省事。这里有个反直觉的点——很多人一上来就按级别拆成四个文件,结果排查一次崩溃要在四个文件里对时间戳,反而更累。我的建议是默认单流,需要时再开级别分流。
2.3 线程安全的最小实现
日志写入是典型的多生产者单消费者场景,Qt 的 GUI 线程、工作线程、网络回调都可能打日志。最省事的做法是LogWriter内部持一把QMutex,所有写操作串行化。别用QReadWriteLock,写日志几乎全是写操作,读写锁在这里没有收益。
class LogWriter { public: static LogWriter& instance() { static LogWriter w; return w; } void write(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) { QMutexLocker locker(&m_mutex); // 全局串行,简单可靠 rotateIfNeeded(); // 先判断要不要换文件 m_stream << formatLine(type, ctx, msg) << '\n'; m_stream.flush(); // 关键:崩溃前的日志必须落盘 } private: QMutex m_mutex; QFile m_file; QTextStream m_stream; // ... };这段的关键在flush()。很多人为了性能把 flush 去掉靠缓冲区,结果程序崩溃时最后几十行日志全丢在内存里,恰恰是最需要的那几行没了。日志量不大的工控场景,每条 flush 的代价完全可以接受;如果确实量大,退一步用定时 flush(比如 200ms 一次),但崩溃前的那条一定要强制 flush。QMutexLocker保证异常安全,函数退出自动解锁,别手写lock/unlock,中途 return 就死锁了。
3. 按日期和大小自动切分:核心逻辑与参数配置
3.1 文件名生成规则
切分的本质是“决定当前该写哪个文件”。我一般用一个currentPath()函数统一算路径,规则是:基础目录 + 前缀 + 日期 + (可选序号)+ 后缀。日期从QDate::currentDate()取,序号在同一天内递增。
QString LogWriter::buildPath(const QDate& date, int index) { QString name = QString("%1_%2").arg(m_prefix, date.toString("yyyy-MM-dd")); if (index > 0) { name += QString("_%1").arg(index, 3, 10, QChar('0')); // _001 } return m_dir.filePath(name + ".log"); }arg(index, 3, 10, QChar('0'))这个写法是把序号补零到 3 位,保证文件在资源管理器里按名字排序时是_001、_002、_010而不是_1、_10、_2。这个细节看着小,但日志文件一多,排序乱了找文件很烦。日期格式用yyyy-MM-dd而不是yyyyMMdd,是为了文件名可读性,代价是多了两个字符,无所谓。
3.2 切分判断的完整流程
rotateIfNeeded()是整套逻辑的心脏,它要同时处理“跨天”和“超大小”两种情况,顺序不能反:
void LogWriter::rotateIfNeeded() { QDate today = QDate::currentDate(); bool needRotate = false; // 条件一:跨天,必须换文件 if (m_currentDate != today) { m_currentDate = today; m_index = 0; needRotate = true; } // 条件二:同一天内超过大小阈值 else if (m_file.isOpen() && m_file.size() >= m_maxSize) { ++m_index; needRotate = true; } if (needRotate) { if (m_file.isOpen()) { m_stream.flush(); m_file.close(); } m_file.setFileName(buildPath(m_currentDate, m_index)); if (!m_file.open(QIODevice::WriteOnly | QIODevice::Append | QIODevice::Text)) { // 打开失败不能静默,退到 stderr fprintf(stderr, "log open failed: %s\n", qPrintable(m_file.errorString())); return; } m_stream.setDevice(&m_file); } }逻辑说明几个点。第一,跨天判断放在最前面,因为跨天时序号必须归零,如果先判大小再判日期,跨天那一刻可能带着旧序号进新日期,文件名就乱了。第二,用QIODevice::Append而不是WriteOnly,防止程序重启后把当天已有日志覆盖掉——这是血泪经验,早期我用WriteOnly,重启一次当天的日志全没了。第三,打开失败一定要有兜底输出,日志系统自己挂了还静默,等于黑匣子坏了,排错时最怕这个。
3.3 参数怎么定:阈值、目录、清理
参数没有标准答案,但有几个经验值可以抄:
- 单文件大小阈值:桌面端 10MB~20MB,工控长期运行 50MB。太小切得太碎,文件多到难管理;太大打开卡。判断依据是“用记事本打开不卡”这条线,大概 20MB 以内。
- 日志目录:别放程序安装目录(Program Files 下无写权限),用
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation),跨平台且权限稳。 - 清理策略:按天数保留,比如只留最近 30 天。启动时扫一遍目录,用
QFileInfo::lastModified()比对,超期的删掉。
void LogWriter::cleanupOldLogs(int keepDays) { QDir dir(m_dir); QDateTime deadline = QDateTime::currentDateTime().addDays(-keepDays); const auto files = dir.entryInfoList({m_prefix + "*.log"}, QDir::Files); for (const QFileInfo& fi : files) { if (fi.lastModified() < deadline) { QFile::remove(fi.absoluteFilePath()); } } }清理放在启动时做一次就够,别每次写日志都扫目录,那是给自己找性能麻烦。entryInfoList的通配符用m_prefix + "*.log",只删自己前缀的文件,避免误删同目录下别的程序日志——这个边界必须卡死,删错文件是生产事故。
4. 避坑与排查:那些让我返工三次的细节
4.1 现象:日志文件里出现乱码和半行
原因:QTextStream默认编码在 Qt5 和 Qt6 下不一致,Qt5 默认本地编码,Qt6 默认 UTF-8;加上多线程下如果没加锁,两个线程同时写会交错成半行。
解决:显式m_stream.setEncoding(QStringConverter::Utf8)(Qt6)或m_stream.setCodec("UTF-8")(Qt5),并且所有写入口必须过同一把锁。别在 handler 外面再包一层自己的锁,容易和内部锁顺序冲突死锁。
4.2 现象:跨天那一刻日志写进了旧日期文件
原因:rotateIfNeeded()里先判断了大小再判断日期,或者日期比较用了缓存值没刷新。
解决:每次写之前重新取QDate::currentDate(),别在构造时缓存。跨天判断永远排第一,判断完立刻更新m_currentDate和m_index。
4.3 现象:程序异常退出,最后几条日志丢失
原因:QTextStream有内部缓冲,没 flush 就崩了。
解决:每条写完flush(),或者至少在qFatal、Q_ASSERT之前手动 flush。更稳的做法是给LogWriter注册qAddPostRoutine或在QCoreApplication::aboutToQuit里做最后一次 flush。
4.4 现象:日志目录在部分机器上创建失败
原因:用了相对路径或程序目录,遇到只读目录或权限受限。
解决:统一用QStandardPaths拿可写目录,QDir().mkpath()递归创建,创建失败时降级到临时目录并打一条 stderr,别让日志系统把主程序拖崩。
4.5 现象:文件大小判断不准,切出来的文件比阈值大很多
原因:QFile::size()反映的是已 flush 到磁盘的大小,如果缓冲区里还压着数据,判断会滞后。
解决:判断前先m_stream.flush(),或者用自己维护的累计字节数(每次写入累加msg.toUtf8().size())做判断,后者更准,代价是要自己算。
5. 进阶:把日志切分做成可配置、可验证的模块
走到这一步,基本功能已经能跑了,但要在团队里长期用,还得解决两件事:参数不能写死,以及怎么证明它真的按预期切了。
先说配置化。把maxSize、keepDays、prefix、level这些抽到一个LogConfig结构体,从QSettings或 JSON 读,启动时加载一次。这样现场调阈值不用重新编译,改配置文件重启即可。我一般会留一个enabled开关,生产环境出问题时能一键关掉日志减少 IO。
struct LogConfig { QString prefix = "app"; qint64 maxSize = 20 * 1024 * 1024; // 20MB int keepDays = 30; bool splitByLevel = false; static LogConfig load(const QString& path) { LogConfig c; QSettings s(path, QSettings::IniFormat); c.prefix = s.value("log/prefix", c.prefix).toString(); c.maxSize = s.value("log/maxSize", c.maxSize).toLongLong(); c.keepDays = s.value("log/keepDays", c.keepDays).toInt(); return c; } };再说验证。切分逻辑最容易出的问题是“看起来在切,其实没切对”,光靠肉眼看文件不够。我的做法是写一个小的验证用例:把maxSize临时设成 1KB,循环打 500 条日志,然后断言目录里文件数大于 1、每个文件大小都不超过阈值太多、文件名序号连续。这个用例跑一遍,跨天逻辑用伪造日期(把currentDate抽成可注入的函数)单独测。
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 大小切分 | maxSize=1KB 打 500 条 | 文件数 > 1,单文件 ≤ 1KB+单条余量 |
| 日期切分 | 注入伪造日期跨天 | 新文件日期正确,序号归零 |
| 清理策略 | 造 40 天前的文件 | 启动后被删除 |
| 并发写入 | 4 线程各打 1000 条 | 无半行、无乱码、总条数对得上 |
并发那条尤其值得跑,我见过太多“单线程测着好好的,一上多线程就串行”的案例。跑通这套验证,这套日志模块才敢往长期运行的项目里放。
最后说个习惯。从那以后我每次接新项目,日志模块一定是第一个搭的,而且搭完先跑一遍上面的验证用例,再写业务代码。因为日志是排错的黑匣子,黑匣子本身不可靠,后面所有排查都是玄学。这套按条件自动切分的思路不复杂,难的是把 flush、锁、路径、清理这些边界一次性想全,希望帮到你。
本文还有配套的精品资源,点击获取