feat: harden logger lifecycle and add tests

This commit is contained in:
2026-07-13 13:22:10 +08:00
parent 95a8067342
commit 62ed176bdd
8 changed files with 799 additions and 190 deletions
+395 -118
View File
@@ -1,157 +1,434 @@
#include "loghandler.h"
#include <iostream>
#include <QDebug>
#include "loghandler.h"
#include <cstdio>
#include <QCoreApplication>
#include <QDate>
#include <QDateTime>
#include <QMutexLocker>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMutex>
#include <QMutexLocker>
#include <QRegularExpression>
#include <QScopedValueRollback>
#include <QTextStream>
#include <QCoreApplication>
QMutex g_mutex;
QFile g_file;
QTextStream g_stream;
qint64 g_lastFlushMs = 0;
// Qt6 将 QTextStream 编码接口改为 QStringConverter 枚举;Qt5 分支继续使用 setCodec
// 条件包含避免 Qt5 公用库因为不存在该头文件而无法构建。
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
#include <QStringConverter>
#endif
#if defined(Q_OS_WIN)
#include <io.h>
#else
#include <unistd.h>
#endif
namespace
{
constexpr qint64 kLogFlushIntervalMs = 1000;
constexpr qint64 kMaxLogFileBytes = 10LL * 1024 * 1024;
constexpr int kLogRetentionDays = 30;
// 消息处理函数
void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
// 日志运行状态集中在一个私有对象中,所有文件访问和安装状态均受同一把锁保护,
// 使重复安装、退出期卸载和后台线程同时输出日志时不会访问到半关闭的 QTextStream。
struct LogState
{
QMutexLocker locker(&g_mutex);
QString level, colorLevel;
QMutex mutex;
QFile file;
QTextStream stream;
QtMessageHandler previousHandler = nullptr;
qint64 lastFlushMs = 0;
qint64 currentFileBytes = 0;
int currentPart = 0;
QDate activeDate;
bool installed = false;
#if defined(MYLOGGER_TESTING)
// 测试编译时将文件系统副作用收敛到临时目录,并用小阈值快速覆盖分卷逻辑。
QString testLogDirectory;
qint64 testMaxLogFileBytes = 0;
#endif
};
switch (type)
{
case QtDebugMsg:
level = "DEBUG";
colorLevel = "\033[36m" + level + "\033[0m"; // 青色
break;
case QtInfoMsg:
level = "INFO ";
colorLevel = "\033[32m" + level + "\033[0m"; // 绿色
break;
case QtWarningMsg:
level = "WARN ";
colorLevel = "\033[33m" + level + "\033[0m"; // 黄色
break;
case QtCriticalMsg:
level = "ERROR";
colorLevel = "\033[31m" + level + "\033[0m"; // 红色
break;
case QtFatalMsg:
level = "FATAL";
colorLevel = "\033[31m" + level + "\033[0m"; // 红色
break;
default:
break;
}
LogState g_logState;
// 输出到日志文件, 格式: 时间 - [Level] (文件名:行数, 函数): 消息
QString fileName = context.file ? QString::fromUtf8(context.file) : QStringLiteral("?");
int index = fileName.lastIndexOf(QDir::separator());
if (index >= 0)
{
fileName = fileName.mid(index + 1);
}
const QString funcName = context.function ? QString::fromUtf8(context.function) : QStringLiteral("?");
const QString lineText = QString::number(context.line);
const QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss.zzz");
const QString consoleMsg = QString("%1-[%2](%3:%4,%5): %6")
.arg(timestamp, colorLevel, fileName, lineText, funcName, msg);
const QString fileMsg = QString("%1-[%2](%3:%4,%5): %6")
.arg(timestamp, level, fileName, lineText, funcName, msg);
std::cout << consoleMsg.toLocal8Bit().constData() << std::endl;
if (g_file.isOpen())
{
g_stream << fileMsg << "\n";
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (type >= QtCriticalMsg || now - g_lastFlushMs >= kLogFlushIntervalMs)
{
g_stream.flush();
g_file.flush();
g_lastFlushMs = now;
}
}
}
static QString LinuxRuntimeRoot()
// Linux 部署包可能由启动脚本指定可写运行根目录,或由 AppImage 挂载在只读位置;
// 这里优先复用 LC 已验证的环境变量策略,未提供时保持各平台原有的应用目录回退行为。
QString runtimeRootPath()
{
#ifdef Q_OS_LINUX
QByteArray root = qgetenv("LC_APP_ROOT");
if(!root.isEmpty())
#if defined(Q_OS_LINUX)
const QByteArray configuredRoot = qgetenv("LC_APP_ROOT");
if (!configuredRoot.isEmpty())
{
return QDir::cleanPath(QString::fromLocal8Bit(root));
return QDir::cleanPath(QString::fromLocal8Bit(configuredRoot));
}
QByteArray appImage = qgetenv("APPIMAGE");
if(!appImage.isEmpty())
const QByteArray appImagePath = qgetenv("APPIMAGE");
if (!appImagePath.isEmpty())
{
return QFileInfo(QString::fromLocal8Bit(appImage)).absolutePath();
return QFileInfo(QString::fromLocal8Bit(appImagePath)).absolutePath();
}
#endif
return QCoreApplication::applicationDirPath();
}
// 给Qt安装消息处理函数
void LogHandler::installMessageHandler()
// 测试覆盖目录优先于部署策略,正式环境则在统一运行根目录下创建 logs,
// 从而同时兼容 Windows exe 同级日志、Linux 启动脚本和 AppImage 部署。
QString logDirectoryPath()
{
static bool installFlag = false;
if(installFlag)
return;
installFlag = true;
//获取日期
auto _currentDate = QDateTime::currentDateTime().toString("yyyy-MM-dd");
//打开/创建文件
#ifdef Q_OS_LINUX
auto _appDirPath = LinuxRuntimeRoot();
#else
auto _appDirPath = QCoreApplication::applicationDirPath();
#endif
auto _logDirPath = _appDirPath + "/logs/";
QDir _logDir(_logDirPath);
if(!_logDir.exists())
_logDir.mkpath(_logDirPath);
QString _logPath;
for(int i = 1; i < 1000; ++i)
#if defined(MYLOGGER_TESTING)
if (!g_logState.testLogDirectory.isEmpty())
{
_logPath = _logDirPath + _currentDate + QString("_%1.log").arg(i);
QFileInfo _fileInfo(_logPath);
if(!_fileInfo.exists())
return g_logState.testLogDirectory;
}
#endif
return runtimeRootPath() + QStringLiteral("/logs");
}
// 正式分卷阈值保持 10 MiB,测试仅通过编译期访问类降低阈值以缩短执行时间。
qint64 maxLogFileBytes()
{
#if defined(MYLOGGER_TESTING)
if (g_logState.testMaxLogFileBytes > 0)
{
return g_logState.testMaxLogFileBytes;
}
#endif
return kMaxLogFileBytes;
}
// Qt 内部或文件错误路径可能再次产生日志;同线程递归时不再进入互斥写盘路径,
// 防止非递归互斥锁死锁,同时保留最小 stderr 输出用于诊断原始故障。
thread_local bool g_isHandlingMessage = false;
// 仅在标准输出确实连接终端时插入 ANSI 颜色,避免控制字符污染重定向文件和 Windows GUI 进程输出。
bool supportsAnsiColor()
{
#if defined(Q_OS_WIN)
return _isatty(_fileno(stdout)) != 0;
#else
return isatty(fileno(stdout)) != 0;
#endif
}
// 将 Qt 日志等级转换为固定宽度文本,未知枚举值也保留可读标识以兼容后续 Qt 扩展。
QString levelName(QtMsgType type)
{
switch (type)
{
case QtDebugMsg:
return QStringLiteral("DEBUG");
case QtInfoMsg:
return QStringLiteral("INFO ");
case QtWarningMsg:
return QStringLiteral("WARN ");
case QtCriticalMsg:
return QStringLiteral("ERROR");
case QtFatalMsg:
return QStringLiteral("FATAL");
default:
return QStringLiteral("UNKWN");
}
}
// 控制台颜色仅是交互显示效果,文件日志始终使用无控制字符的等级文本。
QString consoleLevel(QtMsgType type, const QString &level)
{
if (!supportsAnsiColor())
{
return level;
}
const char *color = "\033[0m";
switch (type)
{
case QtDebugMsg:
color = "\033[36m";
break;
case QtInfoMsg:
color = "\033[32m";
break;
case QtWarningMsg:
color = "\033[33m";
break;
case QtCriticalMsg:
case QtFatalMsg:
color = "\033[31m";
break;
default:
break;
}
return QString::fromLatin1(color) + level + QStringLiteral("\033[0m");
}
// 兼容 Windows 反斜杠与编译器可能输出的正斜杠路径,只在日志中保留文件名以控制行宽。
QString shortFileName(const QMessageLogContext &context)
{
const QString fileName = context.file ? QString::fromUtf8(context.file) : QStringLiteral("?");
const int separatorIndex = qMax(fileName.lastIndexOf(QLatin1Char('/')),
fileName.lastIndexOf(QLatin1Char('\\')));
return separatorIndex >= 0 ? fileName.mid(separatorIndex + 1) : fileName;
}
// 仅清理本模块按日期、进程号和分卷号命名的日志,避免公共 logs 目录中的宿主业务文件被误删。
void removeExpiredLogs(const QDir &logDir, const QDate &today)
{
const QRegularExpression logNamePattern(
QStringLiteral("^\\d{4}-\\d{2}-\\d{2}_\\d+_\\d{3}\\.log$"));
const QDate oldestDate = today.addDays(-kLogRetentionDays);
const QFileInfoList files = logDir.entryInfoList(QStringList() << QStringLiteral("*.log"),
QDir::Files | QDir::Readable);
for (const QFileInfo &fileInfo : files)
{
if (!logNamePattern.match(fileInfo.fileName()).hasMatch())
{
break;
continue;
}
const QDate fileDate = QDate::fromString(fileInfo.fileName().left(10), Qt::ISODate);
if (fileDate.isValid() && fileDate < oldestDate)
{
QFile::remove(fileInfo.absoluteFilePath());
}
}
}
// 在锁保护下选择当前进程当天可继续写入的分卷;已满分卷永不复用,防止重启后突破大小上限。
bool openCurrentLogFileLocked(LogState &state, const QDir &logDir, const QDate &today)
{
const QString prefix = QStringLiteral("%1_%2_")
.arg(today.toString(QStringLiteral("yyyy-MM-dd")))
.arg(QCoreApplication::applicationPid());
int part = 1;
while (true)
{
const QString filePath = logDir.filePath(prefix + QStringLiteral("%1.log").arg(part, 3, 10, QLatin1Char('0')));
const QFileInfo fileInfo(filePath);
if (!fileInfo.exists() || fileInfo.size() < maxLogFileBytes())
{
state.file.setFileName(filePath);
if (!state.file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append))
{
return false;
}
state.stream.setDevice(&state.file);
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
state.stream.setEncoding(QStringConverter::Utf8);
#else
state.stream.setCodec("UTF-8");
#endif
state.currentPart = part;
// QTextStream 可能缓存尚未落盘的内容,分卷判断必须从已打开文件大小开始累计,
// 不能在每次消息时直接依赖 QFile::size(),否则高频日志会在 flush 前突破上限。
state.currentFileBytes = state.file.size();
state.activeDate = today;
return true;
}
++part;
}
}
// 在当前文件即将超过上限时切到下一个空闲分卷;连续序号便于现场按时间顺序定位日志。
bool rotateLogFileLocked(LogState &state)
{
const QFileInfo currentInfo(state.file);
const QDir logDir = currentInfo.dir();
const QString prefix = QStringLiteral("%1_%2_")
.arg(QDate::currentDate().toString(QStringLiteral("yyyy-MM-dd")))
.arg(QCoreApplication::applicationPid());
int part = state.currentPart + 1;
QString filePath;
do
{
filePath = logDir.filePath(prefix + QStringLiteral("%1.log").arg(part, 3, 10, QLatin1Char('0')));
++part;
} while (QFileInfo::exists(filePath));
state.stream.flush();
state.file.flush();
state.file.close();
state.stream.setDevice(nullptr);
state.file.setFileName(filePath);
if (!state.file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append))
{
return false;
}
state.stream.setDevice(&state.file);
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
state.stream.setEncoding(QStringConverter::Utf8);
#else
state.stream.setCodec("UTF-8");
#endif
state.currentPart = part - 1;
state.currentFileBytes = 0;
state.activeDate = QDate::currentDate();
return true;
}
// 全局 Qt 回调负责格式化并串行写盘;致命等级只保证最后一次 flush,终止动作仍由 Qt 在回调返回后执行。
void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
if (g_isHandlingMessage)
{
const QByteArray recursiveMessage = msg.toLocal8Bit();
std::fputs(recursiveMessage.constData(), stderr);
std::fputc('\n', stderr);
return;
}
QScopedValueRollback<bool> recursionGuard(g_isHandlingMessage, true);
const QString level = levelName(type);
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd hh:mm:ss.zzz"));
const QString fileName = shortFileName(context);
const QString functionName = context.function ? QString::fromUtf8(context.function) : QStringLiteral("?");
const QString fileMessage = QStringLiteral("%1-[%2](%3:%4,%5): %6")
.arg(timestamp, level, fileName)
.arg(context.line)
.arg(functionName, msg);
const QString consoleMessage = QStringLiteral("%1-[%2](%3:%4,%5): %6")
.arg(timestamp, consoleLevel(type, level), fileName)
.arg(context.line)
.arg(functionName, msg);
const QByteArray consoleBytes = consoleMessage.toLocal8Bit();
std::fwrite(consoleBytes.constData(), 1, static_cast<size_t>(consoleBytes.size()), stdout);
std::fputc('\n', stdout);
QMutexLocker locker(&g_logState.mutex);
if (!g_logState.installed)
{
return;
}
// 跨午夜时先关闭旧日期文件再按新日期选择分卷;轮转失败后的后续消息也会重试打开,
// 这样临时磁盘或权限故障恢复后无需重启应用即可恢复文件日志。
const QDate today = QDate::currentDate();
if (g_logState.activeDate != today && g_logState.file.isOpen())
{
g_logState.stream.flush();
g_logState.file.flush();
g_logState.file.close();
g_logState.stream.setDevice(nullptr);
g_logState.currentFileBytes = 0;
g_logState.currentPart = 0;
}
if (!g_logState.file.isOpen())
{
const QDir logDir(logDirectoryPath());
if (!openCurrentLogFileLocked(g_logState, logDir, today))
{
return;
}
}
g_file.setFileName(_logPath);
if(!g_file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append))
const qint64 messageBytes = fileMessage.toUtf8().size() + 1;
if (g_logState.currentFileBytes + messageBytes > maxLogFileBytes() && !rotateLogFileLocked(g_logState))
{
return;
}
g_stream.setDevice(&g_file);
g_stream.setCodec("UTF-8");
g_lastFlushMs = QDateTime::currentMSecsSinceEpoch();
qInstallMessageHandler(messageHandler); // 给 Qt 安装自定义消息处理函数
g_logState.stream << fileMessage << '\n';
g_logState.currentFileBytes += messageBytes;
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (type == QtCriticalMsg || type == QtFatalMsg || now - g_logState.lastFlushMs >= kLogFlushIntervalMs)
{
g_logState.stream.flush();
g_logState.file.flush();
g_logState.lastFlushMs = now;
if (type == QtCriticalMsg || type == QtFatalMsg)
{
std::fflush(stdout);
}
}
}
}
// 取消安装消息处理函数并释放资源
// 安装函数只在文件已成功打开后才接管 Qt 全局 handler,失败时保留宿主原有 handler 并允许后续重试。
bool LogHandler::installMessageHandler()
{
QMutexLocker locker(&g_logState.mutex);
if (g_logState.installed)
{
return true;
}
const QDir logDir(logDirectoryPath());
if (!logDir.exists() && !QDir().mkpath(logDir.absolutePath()))
{
return false;
}
const QDate today = QDate::currentDate();
removeExpiredLogs(logDir, today);
if (!openCurrentLogFileLocked(g_logState, logDir, today))
{
return false;
}
g_logState.lastFlushMs = QDateTime::currentMSecsSinceEpoch();
g_logState.previousHandler = qInstallMessageHandler(messageHandler);
g_logState.installed = true;
return true;
}
// 卸载函数在锁内先同步关闭流再恢复旧 handler,确保回调并发进入时不会写到已释放的 QTextStream。
void LogHandler::uninstallMessageHandler()
{
QMutexLocker locker(&g_mutex);
if(g_file.isOpen())
QMutexLocker locker(&g_logState.mutex);
if (!g_logState.installed)
{
g_stream.flush();
g_file.flush();
g_file.close();
return;
}
g_stream.setDevice(nullptr);
qInstallMessageHandler(nullptr);
qInstallMessageHandler(g_logState.previousHandler);
g_logState.previousHandler = nullptr;
if (g_logState.file.isOpen())
{
g_logState.stream.flush();
g_logState.file.flush();
g_logState.file.close();
}
g_logState.stream.setDevice(nullptr);
g_logState.currentFileBytes = 0;
g_logState.currentPart = 0;
g_logState.activeDate = QDate();
g_logState.installed = false;
}
// 查询函数与安装/卸载共用互斥锁,避免其他线程在关闭过程看到过期安装状态。
bool LogHandler::isInstalled() const
{
QMutexLocker locker(&g_logState.mutex);
return g_logState.installed;
}
#if defined(MYLOGGER_TESTING)
// 测试目录设置在同一把锁下更新,并要求 handler 已卸载,保证没有线程持有旧目录的文件句柄。
void LogHandlerTestAccess::setLogDirectory(const QString &directory)
{
QMutexLocker locker(&g_logState.mutex);
Q_ASSERT(!g_logState.installed);
g_logState.testLogDirectory = directory;
}
// 分卷阈值只影响随后打开的测试文件;非正值表示删除覆盖并回退到正式默认值。
void LogHandlerTestAccess::setMaxLogFileBytes(qint64 bytes)
{
QMutexLocker locker(&g_logState.mutex);
Q_ASSERT(!g_logState.installed);
g_logState.testMaxLogFileBytes = bytes > 0 ? bytes : 0;
}
// 测试结束后统一清除覆盖状态,避免同一 QtTest 进程中的下一条用例意外复用临时环境。
void LogHandlerTestAccess::reset()
{
QMutexLocker locker(&g_logState.mutex);
Q_ASSERT(!g_logState.installed);
g_logState.testLogDirectory.clear();
g_logState.testMaxLogFileBytes = 0;
}
#endif