戰(zhàn):用qInstallMessageHandler實(shí)現(xiàn)自動(dòng)分文件日志系統(tǒng))
簡(jiǎn)介面向Qt/C開(kāi)發(fā)人員的日志管理示例工程主要解決如何按設(shè)定條件自動(dòng)保存日志并創(chuàng)建新文件的問(wèn)題適用于桌面應(yīng)用調(diào)試、運(yùn)行監(jiān)控以及需要長(zhǎng)期穩(wěn)定運(yùn)行的服務(wù)端程序。壓縮包共6個(gè)文件包含2個(gè)cpp源文件、1個(gè)頭文件、1個(gè)pro工程文件、1份說(shuō)明文檔及1個(gè)user配置文件整體僅6KB內(nèi)容精煉但完整覆蓋了日志系統(tǒng)的核心環(huán)節(jié)。目前已有2378人學(xué)習(xí)下載。該小型SaveLogPro項(xiàng)目基于QFile、QTextStream、QDir、QFileInfo等類展示了從設(shè)置日志存儲(chǔ)路徑、判斷文件當(dāng)前大小到超過(guò)閾值自動(dòng)滾動(dòng)生成新日志文件的完整處理流程寫(xiě)入前進(jìn)行文件大小檢測(cè)可有效避免單文件無(wú)限增長(zhǎng)。讀者既可以將這個(gè)日志類稍作改造后直接復(fù)用也能夠以此為基礎(chǔ)擴(kuò)展日志級(jí)別、時(shí)間戳、線程ID等元數(shù)據(jù)使日志更具可讀性和分析價(jià)值對(duì)提升大型Qt項(xiàng)目的可維護(hù)性很有幫助。1. 項(xiàng)目概述做QT桌面應(yīng)用開(kāi)發(fā)的遲早要面對(duì)一個(gè)問(wèn)題程序跑著跑著崩了、界面卡了、功能沒(méi)響應(yīng)了你拿什么去復(fù)盤(pán)看控制臺(tái)輸出顯然不現(xiàn)實(shí)用戶也不會(huì)配合你遠(yuǎn)程調(diào)試。這個(gè)時(shí)候一套可靠的日志系統(tǒng)就是唯一的救命稻草。這個(gè)項(xiàng)目要解決的就是QT應(yīng)用中“日志保存與自動(dòng)分文件”這件事。核心需求不復(fù)雜程序在運(yùn)行過(guò)程中把調(diào)試信息、警告、錯(cuò)誤等內(nèi)容寫(xiě)入本地文件文件不能無(wú)限增長(zhǎng)得有一個(gè)“切割”機(jī)制要么按日期切換要么按大小切換寫(xiě)滿一個(gè)就自動(dòng)開(kāi)一個(gè)新的舊文件保留下來(lái)供后續(xù)排查。比直接寫(xiě)死一個(gè)日志文件高級(jí)在哪舉一個(gè)我實(shí)際遇到過(guò)的場(chǎng)景某次發(fā)布版本后用戶反饋程序使用幾天后越來(lái)越卡。如果沒(méi)有分級(jí)切割我要面對(duì)的是一個(gè)幾百兆的log文件打開(kāi)都費(fèi)勁更別提定位問(wèn)題了。而按條件自動(dòng)分割之后直接看對(duì)應(yīng)日期、對(duì)應(yīng)時(shí)段的小文件幾秒鐘就能鎖定問(wèn)題。這就是這套系統(tǒng)的價(jià)值所在——它把“事后排查”這件事的復(fù)雜度降了一個(gè)量級(jí)。適合誰(shuí)來(lái)參考正在用QT做上位機(jī)、客戶端、嵌入式配套工具的開(kāi)發(fā)者都適用。尤其是那些產(chǎn)品已經(jīng)交付出去、需要遠(yuǎn)程排查問(wèn)題的情況這套方案幾乎是剛需。你不需要引入第三方庫(kù)純QT模塊就能實(shí)現(xiàn)邏輯清晰遷到別的項(xiàng)目里也就是復(fù)制粘貼的事。2. 整體方案設(shè)計(jì)從需求到選型2.1 幾種日志保存方式的對(duì)比做日志系統(tǒng)之前我先后試過(guò)好幾種路子踩過(guò)不少坑之后才定下最終的方案。這里把幾種常見(jiàn)方式的優(yōu)缺點(diǎn)攤開(kāi)來(lái)說(shuō)方便你判斷哪種適合自己。第一種標(biāo)準(zhǔn)輸出重定向。把stdout和stderr重定向到文件優(yōu)點(diǎn)是改動(dòng)最小缺點(diǎn)是拿不到qDebug的輸出而且文件切割邏輯得靠外部腳本做程序內(nèi)部完全沒(méi)有控制力。第二種QFile手動(dòng)寫(xiě)入。好處是可控坑在于所有需要記日志的地方都得手動(dòng)寫(xiě)文件操作語(yǔ)句代碼侵入性很強(qiáng)。項(xiàng)目里模塊一多很容易出現(xiàn)“有些地方記了、有些地方漏了”的情況。第三種重寫(xiě)qInstallMessageHandler自定義日志處理器配合QFile和定時(shí)器做自動(dòng)切割。這是我現(xiàn)在用的方案也是這個(gè)項(xiàng)目采用的。它的優(yōu)勢(shì)在于所有通過(guò)qDebug、qWarning、qCritical輸出的信息都會(huì)被統(tǒng)一接管“按天切割”“按大小切割”“按日志級(jí)別過(guò)濾”這些規(guī)則可以全部收斂在一個(gè)類里實(shí)現(xiàn)業(yè)務(wù)代碼里只需要正常使用Qt的日志宏不需要額外關(guān)心“這條日志會(huì)寫(xiě)到哪個(gè)文件”。最終我選第三種的另一個(gè)原因是排查效率上的實(shí)打?qū)嵉奶嵘隽藛?wèn)題的時(shí)候直接按照時(shí)間范圍和文件大小快速定位到那一天、那一個(gè)時(shí)間段的小日志文件整個(gè)排查鏈路會(huì)短很多。2.2 為什么用qInstallMessageHandler而不是自定義類有些開(kāi)發(fā)者習(xí)慣自己定義一個(gè)Logger類然后在每個(gè)模塊里調(diào)用Logger::write(...)。這個(gè)方案不是不行但有一個(gè)很現(xiàn)實(shí)的問(wèn)題中途接手項(xiàng)目的人或者你自己隔了半年回來(lái)看代碼很容易忘記哪些地方應(yīng)該寫(xiě)日志、應(yīng)該用哪種方式寫(xiě)。而且在快速原型階段你往往希望頻繁用qDebug()輸出臨時(shí)變量如果業(yè)務(wù)代碼里沒(méi)有日志入口這些輸出就會(huì)丟失。qInstallMessageHandler的方案直接把“日志路由”這件事統(tǒng)一接管了不管你在哪個(gè)文件里寫(xiě)的qDebug()、qWarning()、qCritical()最終都會(huì)流到我自定義的回調(diào)函數(shù)里面去。這是Qt官方提供的機(jī)制穩(wěn)定可靠用法也簡(jiǎn)單。static void messageHandler(QtMsgType type, const QMessageLogContext context, const QString msg);注冊(cè)方式是這么一行qInstallMessageHandler(messageHandler);一旦注冊(cè)完成任何日志輸出都會(huì)進(jìn)到這個(gè)函數(shù)里來(lái)你再根據(jù)類型決定是直接丟棄、只記文件還是同時(shí)輸出到控制臺(tái)。這種模式的優(yōu)雅之處在于業(yè)務(wù)代碼完全不需要感知日志系統(tǒng)的存在只需要正常寫(xiě)Qt日志宏即可。我更傾向于在回調(diào)里保留控制臺(tái)輸出這樣開(kāi)發(fā)調(diào)試階段可以直接在IDE的“應(yīng)用程序輸出窗口”看到日志部署給客戶時(shí)再關(guān)閉這個(gè)開(kāi)關(guān)。3. 核心實(shí)現(xiàn)日志切割與文件管理3.1 目錄規(guī)劃與命名規(guī)則先規(guī)劃目錄別一上來(lái)就寫(xiě)代碼。我一般會(huì)在程序啟動(dòng)時(shí)創(chuàng)建logs目錄路徑不寫(xiě)死而是放在程序同級(jí)目錄下。這樣打包分發(fā)的時(shí)候整個(gè)日志目錄可以被一鍵打包給開(kāi)發(fā)方。核心代碼是這三行QDir dir(QCoreApplication::applicationDirPath() /logs); if (!dir.exists()) { dir.mkpath(.); }文件命名規(guī)則建議采用“日期 序號(hào)”的組合比如app_20250101_001.log。為什么要加序號(hào)單純按日期命名的話同一天內(nèi)如果觸發(fā)了按大小切割就會(huì)產(chǎn)生多個(gè)文件沒(méi)有序號(hào)的話后寫(xiě)的文件會(huì)覆蓋先寫(xiě)的文件。完整的命名邏輯如下QString logBasePath QCoreApplication::applicationDirPath() /logs; QString currentDate QDate::currentDate().toString(yyyyMMdd); QString fileName QString(%1/app_%2_%3.log) .arg(logBasePath, currentDate) .arg(currentFileIndex, 3, 10, QLatin1Char(0));3.2 按日期自動(dòng)切割的實(shí)現(xiàn)按日期切割原理非常簡(jiǎn)單在寫(xiě)入每一條日志之前檢查“當(dāng)前日期”和“正在使用的文件所屬日期”是否一致不一致就關(guān)閉當(dāng)前文件、創(chuàng)建新文件。有個(gè)邊界情況必須注意日期切換的那一剎那程序還在跑正好有幾條日志還在排隊(duì)寫(xiě)入。所以“日期變了”的判斷邏輯應(yīng)該是每次寫(xiě)入時(shí)都去QDate::currentDate()取一次當(dāng)前日期而不是依賴定時(shí)器定時(shí)觸發(fā)。定時(shí)器方案的臟數(shù)據(jù)問(wèn)題很頭疼——如果定時(shí)器間隔設(shè)大了跨天瞬間的日志可能被寫(xiě)進(jìn)前一天的舊文件里而且文件流轉(zhuǎn)時(shí)機(jī)變得不可預(yù)測(cè)。我直接用懶判斷的方式QDate today QDate::currentDate(); if (today ! currentLogDate) { closeFile(); currentLogDate today; currentFileIndex 0; openNewFile(); }這段判斷放在消息回調(diào)的最前面每次寫(xiě)入日志時(shí)都執(zhí)行。因?yàn)橹皇莾蓚€(gè)整數(shù)比較開(kāi)銷可以忽略不計(jì)完全不影響程序運(yùn)行。3.3 按大小自動(dòng)切割的實(shí)現(xiàn)按大小切割就是給當(dāng)前日志文件設(shè)一個(gè)閾值比如5 * 1024 * 1024也就是5MB每次寫(xiě)入前檢查當(dāng)前文件大小是否超過(guò)閾值。超過(guò)就關(guān)閉當(dāng)前文件序號(hào)加一再新建一個(gè)文件繼續(xù)寫(xiě)。實(shí)現(xiàn)起來(lái)很直接static qint64 maxFileSize 5 * 1024 * 1024; void checkFileSize(const QString filePath) { QFileInfo info(filePath); if (info.size() maxFileSize) { closeFile(); currentFileIndex; openNewFile(); } }有一個(gè)細(xì)節(jié)值得提不建議在每次寫(xiě)入日志后都立刻flush()。雖然flush()能保證日志數(shù)據(jù)完整落盤(pán)但高頻日志場(chǎng)景下會(huì)嚴(yán)重拖慢程序性能。我的做法是設(shè)置一個(gè)“緩沖行數(shù)”的計(jì)數(shù)累計(jì)到一定數(shù)量或級(jí)別很高比如錯(cuò)誤級(jí)別才執(zhí)行flush。3.4 完整的消息處理回調(diào)下面把日志回調(diào)函數(shù)完整展示出來(lái)這個(gè)類基本就可以直接拿去用了void messageHandler(QtMsgType type, const QMessageLogContext context, const QString msg) { QMutexLocker locker(logMutex); QString level; switch (type) { case QtDebugMsg: level DEBUG; break; case QtInfoMsg: level INFO; break; case QtWarningMsg: level WARN; break; case QtCriticalMsg: level ERROR; break; case QtFatalMsg: level FATAL; break; } QString timestamp QDateTime::currentDateTime().toString(yyyy-MM-dd hh:mm:ss.zzz); QString line QString(%1 [%2] %3) .arg(timestamp, level, msg); if (enableConsoleOutput) { qDebug().noquote() line; } if (enableFileOutput) { checkDateChange(); checkFileSize(); if (currentFile currentFile-isOpen()) { QTextStream stream(currentFile); stream line Qt::endl; pendingLines; if (pendingLines maxPendingLines || type QtFatalMsg) { stream.flush(); pendingLines 0; } } } if (type QtFatalMsg) { abort(); } }核心注意點(diǎn)有三處。第一互斥鎖QMutexLocker是必須的。QT應(yīng)用的日志可能來(lái)自工作線程也可能來(lái)自界面線程存在并發(fā)寫(xiě)文件的可能。如果沒(méi)有鎖保護(hù)日志文件會(huì)損壞或錯(cuò)亂。第二輸出到控制臺(tái)用的是qDebug().noquote() line而不是qDebug() line。區(qū)別在于noquote()會(huì)去掉字符串值的引號(hào)控制臺(tái)上顯示更干凈日志文件里也不會(huì)出現(xiàn)莫名其妙的雙冒號(hào)。第三QtFatalMsg處理完后要abort()。這個(gè)級(jí)別代表程序出現(xiàn)了不可恢復(fù)的錯(cuò)誤繼續(xù)往下走只會(huì)產(chǎn)生更多不可控行為。4. 實(shí)操過(guò)程中最容易踩的坑4.1 中文亂碼問(wèn)題這是最經(jīng)典的老大難問(wèn)題。早期版本的QT在Windows上QTextStream默認(rèn)編碼不一定是UTF-8如果你的日志里有中文打開(kāi)一看全是亂碼等于啥也看不見(jiàn)。解決辦法是顯式指定編碼QTextStream stream(currentFile); stream.setCodec(UTF-8);如果你定位問(wèn)題的工具是Windows記事本而且用的QT版本比較老可以考慮用GBK/GB2312。但放在今天統(tǒng)一UTF-8是最省心的。4.2 文件句柄沒(méi)有及時(shí)釋放切割文件時(shí)如果沒(méi)先關(guān)閉舊文件新文件就會(huì)創(chuàng)建失敗日志繼續(xù)往舊文件寫(xiě)。你在日志里可能看不到任何報(bào)錯(cuò)但實(shí)際上日志文件早就超出預(yù)期大小了。判斷依據(jù)是程序運(yùn)行很久之后發(fā)現(xiàn)每個(gè)文件的大小都遠(yuǎn)超你設(shè)定的切割閾值或者出現(xiàn)大量0字節(jié)文件。解決辦法是切割前檢查文件是否打開(kāi)必須先f(wàn)lush()再close()。4.3 flush策略導(dǎo)致的數(shù)據(jù)丟失口碑前面說(shuō)了高頻日志場(chǎng)景不要每條都flush。但如果程序進(jìn)程在日志還沒(méi)落盤(pán)時(shí)崩潰了這段時(shí)間的日志就會(huì)丟失。我的建議是qCritical()及以上級(jí)別的日志無(wú)條件flushqDebug()級(jí)別的則累計(jì)滿50條再flush。這樣既保證性能又把關(guān)鍵錯(cuò)誤層面的丟失風(fēng)險(xiǎn)壓到最低。4.4 日志目錄空間無(wú)限膨脹如果程序長(zhǎng)期運(yùn)行且“按大小切割”生成的日志文件數(shù)量過(guò)多磁盤(pán)空間會(huì)被慢慢耗盡。服務(wù)類程序跑幾個(gè)月不重啟的話幾十GB的日志文件堆在那里是很正常的??刂撇呗允羌右粭l“保留最近N天”的邏輯在每次創(chuàng)建新文件的時(shí)候掃一遍目錄下的日志文件超過(guò)保留期限的舊文件直接刪除void cleanOldLogs() { QDir dir(QCoreApplication::applicationDirPath() /logs); QStringList filters; filters app_*.log; QFileInfoList fileList dir.entryInfoList(filters, QDir::Files, QDir::Name); QDate expireDate QDate::currentDate().addDays(-30); for (const QFileInfo fi : fileList) { QString datePart fi.baseName().split(_).at(1); QDate fileDate QDate::fromString(datePart, yyyyMMdd); if (fileDate expireDate) { QFile::remove(fi.absoluteFilePath()); } } }5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 經(jīng)典問(wèn)題速查表| 問(wèn)題現(xiàn)象 | 可能原因 | 排查/解決方案 | |---------|---------|| | 日志文件亂碼 | 編碼沒(méi)有顯式設(shè)置 |stream.setCodec(UTF-8)| | 日志文件一直不切割 | 忘記調(diào)用checkFileSize()| 在寫(xiě)入路徑中加上按大小檢查 | | 日志文件都在同一天但序號(hào)不遞增 | 文件名命名重復(fù)被覆蓋 | 序號(hào)需要在日期切換時(shí)重置同一天繼續(xù)累加 | | 輸出到文件和輸出到控制臺(tái)內(nèi)容不一致 | 控制臺(tái)與文件走的不是同一個(gè)handler | 確認(rèn)使用的日志框架只有一個(gè)入口 | | 程序崩潰時(shí)最后的日志丟失 | flush策略持寬松 | 對(duì)qCritical以上級(jí)別無(wú)條件flush | | 多線程同時(shí)寫(xiě)日志導(dǎo)致亂行 | 并發(fā)訪問(wèn)文件 | 加QMutexLocker保護(hù) |5.2 實(shí)際項(xiàng)目中的一次定位過(guò)程說(shuō)一個(gè)真實(shí)案例。之前做一個(gè)數(shù)據(jù)采集的上位機(jī)運(yùn)行一段時(shí)間后采集數(shù)據(jù)異常用戶反饋“數(shù)據(jù)丟幀嚴(yán)重”。我在現(xiàn)場(chǎng)也復(fù)現(xiàn)不了光靠看界面狀態(tài)根本定位不了原因。后來(lái)就是在日志系統(tǒng)里加了一條關(guān)鍵數(shù)據(jù)的時(shí)間戳打印跑了一晚上第二天看日志文件很快就鎖定了問(wèn)題每幀數(shù)據(jù)的時(shí)間間隔在某個(gè)特定操作后從10ms變成了50ms明顯有阻塞。順著日志里出現(xiàn)的調(diào)用點(diǎn)排查最后發(fā)現(xiàn)是某個(gè)線程池的隊(duì)列滿了任務(wù)阻塞在入隊(duì)環(huán)節(jié)。如果沒(méi)有按天切割、按大小切割的日志系統(tǒng)我要從幾萬(wàn)行的日志里找特征費(fèi)時(shí)費(fèi)力不說(shuō)大概率還會(huì)漏掉關(guān)鍵線索。這套系統(tǒng)投入的成本不高但價(jià)值在這個(gè)場(chǎng)景里體現(xiàn)得淋漓盡致。6. 后續(xù)擴(kuò)展思路這套基礎(chǔ)框架搭好之后擴(kuò)展空間其實(shí)很大。我這里列幾個(gè)我自己陸續(xù)加進(jìn)去的功能供參考。一是添加日志級(jí)別過(guò)濾提供一個(gè)全局開(kāi)關(guān)發(fā)布版默認(rèn)只記錄WARN及以上調(diào)試版記錄所有級(jí)別。二是按模塊分流。如果你的項(xiàng)目模塊很多可以使用QLoggingCategory進(jìn)行分門(mén)別類地管理日志各模塊寫(xiě)各自的文件排查問(wèn)題時(shí)不用在一堆日志里撈特定模塊的內(nèi)容。三是封裝成一個(gè)小工具類把打開(kāi)文件、切割判斷、舊文件清理這些邏輯聚合成一個(gè)類方便被多個(gè)項(xiàng)目復(fù)用到。我最后是把它抽成了SimpleLogger整個(gè)類不到兩百行放到哪里都不覺(jué)得負(fù)擔(dān)。四是可以把“按大小切割”和“按日期切割”做成兩種可以同時(shí)啟用的策略再主動(dòng)拋出一個(gè)定期掃描機(jī)制而不只是在創(chuàng)建新文件時(shí)才清理舊文件對(duì)于長(zhǎng)期運(yùn)行的進(jìn)程更友好一些。核心點(diǎn)始終就一個(gè)日志系統(tǒng)是用來(lái)幫人快速定位問(wèn)題的不是給程序增加負(fù)擔(dān)的。在性能和可靠性之間找到適合自己的平衡點(diǎn)這才是它存在的意義。本文還有配套的精品資源點(diǎn)擊獲取