445 lines
15 KiB
C++
445 lines
15 KiB
C++
#include "loghandler.h"
|
||
|
||
#include <cstdio>
|
||
|
||
#include <QCoreApplication>
|
||
#include <QDate>
|
||
#include <QDateTime>
|
||
#include <QDebug>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QFileInfo>
|
||
#include <QMutex>
|
||
#include <QMutexLocker>
|
||
#include <QRegularExpression>
|
||
#include <QScopedValueRollback>
|
||
#include <QTextStream>
|
||
|
||
// 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;
|
||
|
||
// 日志运行状态集中在一个私有对象中,所有文件访问和安装状态均受同一把锁保护,
|
||
// 使重复安装、退出期卸载和后台线程同时输出日志时不会访问到半关闭的 QTextStream。
|
||
struct LogState
|
||
{
|
||
QMutex mutex;
|
||
QFile file;
|
||
QTextStream stream;
|
||
QtMessageHandler previousHandler = nullptr;
|
||
qint64 lastFlushMs = 0;
|
||
qint64 currentFileBytes = 0;
|
||
int currentPart = 0;
|
||
QDate activeDate;
|
||
QString currentFilePrefix;
|
||
bool installed = false;
|
||
#if defined(MYLOGGER_TESTING)
|
||
// 测试编译时将文件系统副作用收敛到临时目录,并用小阈值快速覆盖分卷逻辑。
|
||
QString testLogDirectory;
|
||
qint64 testMaxLogFileBytes = 0;
|
||
#endif
|
||
};
|
||
|
||
LogState g_logState;
|
||
|
||
// Linux 部署包可能由启动脚本指定可写运行根目录,或由 AppImage 挂载在只读位置;
|
||
// 这里优先复用 LC 已验证的环境变量策略,未提供时保持各平台原有的应用目录回退行为。
|
||
QString runtimeRootPath()
|
||
{
|
||
#if defined(Q_OS_LINUX)
|
||
const QByteArray configuredRoot = qgetenv("LC_APP_ROOT");
|
||
if (!configuredRoot.isEmpty())
|
||
{
|
||
return QDir::cleanPath(QString::fromLocal8Bit(configuredRoot));
|
||
}
|
||
|
||
const QByteArray appImagePath = qgetenv("APPIMAGE");
|
||
if (!appImagePath.isEmpty())
|
||
{
|
||
return QFileInfo(QString::fromLocal8Bit(appImagePath)).absolutePath();
|
||
}
|
||
#endif
|
||
|
||
return QCoreApplication::applicationDirPath();
|
||
}
|
||
|
||
// 测试覆盖目录优先于部署策略,正式环境则在统一运行根目录下创建 logs,
|
||
// 从而同时兼容 Windows exe 同级日志、Linux 启动脚本和 AppImage 部署。
|
||
QString logDirectoryPath()
|
||
{
|
||
#if defined(MYLOGGER_TESTING)
|
||
if (!g_logState.testLogDirectory.isEmpty())
|
||
{
|
||
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;
|
||
}
|
||
|
||
// 日志文件以一次打开日志时的本地秒级时间作为会话标识;分卷只追加序号,
|
||
// 既能让现场直接看出开始时间,也避免把进程 ID 暴露到文件名中。
|
||
QString createLogFilePrefix(const QDateTime &dateTime)
|
||
{
|
||
return dateTime.toString(QStringLiteral("yyyy-MM-dd_hhmmss"));
|
||
}
|
||
|
||
// 仅清理本模块按“日期_时分秒_分卷”命名的日志,避免公共 logs 目录中的宿主业务文件被误删。
|
||
void removeExpiredLogs(const QDir &logDir, const QDate &today)
|
||
{
|
||
const QRegularExpression logNamePattern(
|
||
QStringLiteral("^\\d{4}-\\d{2}-\\d{2}_\\d{6}_\\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())
|
||
{
|
||
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)
|
||
{
|
||
if (state.currentFilePrefix.isEmpty() || state.activeDate != today)
|
||
{
|
||
state.currentFilePrefix = createLogFilePrefix(QDateTime::currentDateTime());
|
||
}
|
||
const QString prefix = state.currentFilePrefix + QLatin1Char('_');
|
||
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 = state.currentFilePrefix + QLatin1Char('_');
|
||
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;
|
||
}
|
||
}
|
||
|
||
const qint64 messageBytes = fileMessage.toUtf8().size() + 1;
|
||
if (g_logState.currentFileBytes + messageBytes > maxLogFileBytes() && !rotateLogFileLocked(g_logState))
|
||
{
|
||
return;
|
||
}
|
||
|
||
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_logState.mutex);
|
||
if (!g_logState.installed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
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.currentFilePrefix.clear();
|
||
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
|