diff --git a/CMakeLists.txt b/CMakeLists.txt index 799b660..d229110 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,34 @@ -project(MyLogger) +cmake_minimum_required(VERSION 3.16) -add_library(${PROJECT_NAME} ${PROJECT_SOURCE_DIR}/loghandler.cpp) +project(MyLogger LANGUAGES CXX) -target_link_libraries(${PROJECT_NAME} - Qt5::Core - ) \ No newline at end of file +# 同时识别 Qt5 与 Qt6,避免公共模块把消费者固定在单一 Qt 主版本。 +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Core) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core) + +add_library(MyLogger SHARED + loghandler.cpp + loghandler.h +) + +# 目标自身导出符号,使用方只获得公共头文件、C++17 要求和 Qt Core 传递依赖。 +target_compile_features(MyLogger PUBLIC cxx_std_17) +target_compile_definitions(MyLogger PRIVATE MYLOGGER_LIBRARY QT_MESSAGELOGCONTEXT) +target_include_directories(MyLogger PUBLIC $) +target_link_libraries(MyLogger PUBLIC Qt${QT_VERSION_MAJOR}::Core) + +# 测试目标直接编译日志源码并开启测试访问宏,避免依赖已安装 DLL,也不改变正式库的公开行为。 +include(CTest) +if(BUILD_TESTING) + find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) + add_executable(MyLoggerTests + tests/tst_loghandler.cpp + loghandler.cpp + loghandler.h + ) + target_compile_features(MyLoggerTests PRIVATE cxx_std_17) + target_compile_definitions(MyLoggerTests PRIVATE MYLOGGER_STATIC MYLOGGER_TESTING QT_MESSAGELOGCONTEXT) + target_include_directories(MyLoggerTests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + target_link_libraries(MyLoggerTests PRIVATE Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test) + add_test(NAME MyLoggerTests COMMAND MyLoggerTests) +endif() diff --git a/MyLogger.pri b/MyLogger.pri index f1a99b1..4152752 100644 --- a/MyLogger.pri +++ b/MyLogger.pri @@ -1,7 +1,9 @@ +# 本 pri 用于把 MyLogger 源码直接嵌入 qmake 宿主;不得同时链接 MyLogger 动态库, +# 否则同一进程会拥有两份全局日志状态并争夺 Qt 的唯一消息处理器。 +QT += core +CONFIG += c++17 +DEFINES += MYLOGGER_STATIC QT_MESSAGELOGCONTEXT + INCLUDEPATH += $$PWD/ - -#在release下输出debug信息 -DEFINES += QT_MESSAGELOGCONTEXT - -SOURCES += \ - $$PWD/loghandler.cpp +HEADERS += $$PWD/loghandler.h +SOURCES += $$PWD/loghandler.cpp diff --git a/MyLogger.pro b/MyLogger.pro index defa22a..6c11c50 100644 --- a/MyLogger.pro +++ b/MyLogger.pro @@ -1,25 +1,20 @@ QT += core TEMPLATE = lib -DEFINES += MYLOGGER_LIBRARY +TARGET = MyLogger +# 动态库自身导出 API,并强制保留 Qt 消息上下文;消费者只通过头文件中的导入宏引用符号。 +DEFINES += MYLOGGER_LIBRARY QT_MESSAGELOGCONTEXT CONFIG += c++17 INCLUDEPATH += $$PWD/ - DESTDIR = $$PWD/../../Build/Libs MOC_DIR = $$PWD/Build/moc OBJECTS_DIR = $$PWD/Build/objs RCC_DIR = $$PWD/Build/resources UI_DIR = $$PWD/Build/ui -#在release下输出debug信息 -DEFINES += QT_MESSAGELOGCONTEXT - -HEADERS += \ - $$PWD/loghandler.h - -SOURCES += \ - $$PWD/loghandler.cpp +HEADERS += $$PWD/loghandler.h +SOURCES += $$PWD/loghandler.cpp unix { target.path = /usr/lib diff --git a/README.md b/README.md index 9191977..ea017d4 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,59 @@ -## 概述 +# MyLogger -- Singleton.h +MyLogger 是用于 Qt 程序的进程级日志模块,接管 `qDebug()`、`qInfo()`、`qWarning()`、`qCritical()` 和 `qFatal()`。 -懒汉模式的单例模板类 +## 行为 -- loghandler.h/cpp +- 默认写入 `{applicationDirPath}/logs`,日志文件名为 `yyyy-MM-dd_进程号_分卷号.log`。 +- 单个文件最大 10 MiB,达到上限自动创建下一个分卷;仅清理本模块生成且超过 30 天的日志。 +- 普通日志最多每秒 flush 一次;`qCritical()` 和 `qFatal()` 会立即 flush。 +- 安装时保存此前的 Qt 消息处理器,卸载时恢复它。Qt 每个进程只能有一个全局消息处理器,安装期间不要由其他模块再次调用 `qInstallMessageHandler()`。 -日志类 +## qmake:链接动态库 -- MyLogger.pri +先构建 `MyLogger.pro`,然后在宿主工程中链接生成的库并包含头文件。不要再包含 `MyLogger.pri`。 -子模块qt pri文件 +```pro +LIBS += -L$$PWD/Libs/MyLogger/build -lMyLogger +INCLUDEPATH += $$PWD/Libs/MyLogger +``` -## 使用 +## qmake:源码嵌入 -### 克隆代码 - -克隆代码并复制到工程Libs目录下 - -### 添加子工程 - -在主工程pro文件中,添加: +不单独构建动态库时,可在宿主 `.pro` 中使用: ```pro include($$PWD/Libs/MyLogger/MyLogger.pri) ``` -### 使用 +源码嵌入与链接动态库只能二选一。 -#### 日志使用 +## 使用 -```c++ -#include -//头文件 +在 `Q(Core)Application` 创建后、需要记录第一条 Qt 日志前安装;应用退出前卸载。 + +```cpp #include "loghandler.h" -int main(int argc, char *argv[]) + +if (!LogHandler::Get().installMessageHandler()) { - QApplication a(argc, argv); - //安装 - LogHandler::Get().installMessageHandler(); - Debug() << "Hello"; - qDebug() << "当前时间是: " << QTime::currentTime().toString("hh:mm:ss"); - qInfo() << QString("God bless you!"); - //卸载 - LogHandler::Get().uninstallMessageHandler(); - return a.exec(); -} -``` - - - -#### 单例模板类使用 - -```c++ -#include "Singleton.h" -class AppConfig : public QObject -{ - Q_OBJECT - //通过宏 - SINGLETON(AppConfig); - ..... + // 日志目录不可用;应用可继续运行,也可在权限恢复后再次尝试安装。 } +qInfo() << "application started"; + +LogHandler::Get().uninstallMessageHandler(); ``` +## 单元测试 +`tests/MyLoggerTests.pro` 使用 QtTest 直接编译日志源码,并通过仅测试可见的 `MYLOGGER_TESTING` 访问类把日志写入临时目录。 +```powershell +cd Libs/MyLogger/tests +qmake MyLoggerTests.pro +mingw32-make -j12 +.\release\MyLoggerTests.exe +``` + +测试覆盖安装失败后的重试、旧 handler 恢复、UTF-8 内容、并发写入、分卷与过期日志清理。CMake 使用 `-DBUILD_TESTING=ON` 后可通过 `ctest` 执行同一套用例。 diff --git a/loghandler.cpp b/loghandler.cpp index b1a8dc2..8e845f2 100644 --- a/loghandler.cpp +++ b/loghandler.cpp @@ -1,157 +1,434 @@ -#include "loghandler.h" -#include -#include +#include "loghandler.h" + +#include + +#include +#include #include -#include +#include #include #include #include +#include +#include +#include +#include #include -#include -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 +#endif + +#if defined(Q_OS_WIN) +#include +#else +#include +#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 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(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 diff --git a/loghandler.h b/loghandler.h index 1296555..c6f9fb8 100644 --- a/loghandler.h +++ b/loghandler.h @@ -1,13 +1,37 @@ -#ifndef LOGHANDLER_H +#ifndef LOGHANDLER_H #define LOGHANDLER_H -#include +#include -class Q_DECL_EXPORT LogHandler +#if defined(MYLOGGER_TESTING) +#include +#endif + +// 公用库在动态库、源码嵌入和静态链接三种场景下使用不同导出声明, +// 避免消费者把库符号错误标记为导出符号;源码嵌入模式由 MyLogger.pri 定义 MYLOGGER_STATIC。 +#if defined(MYLOGGER_STATIC) +# define MYLOGGER_EXPORT +#elif defined(MYLOGGER_LIBRARY) +# define MYLOGGER_EXPORT Q_DECL_EXPORT +#else +# define MYLOGGER_EXPORT Q_DECL_IMPORT +#endif + +// 进程级 Qt 消息处理器封装类,负责把 qDebug 等全局日志安全地写入本地文件。 +// 同一进程只能安装一个 Qt 消息处理器,因此宿主应把其安装和卸载权交给同一处生命周期管理代码。 +class MYLOGGER_EXPORT LogHandler { public: - void installMessageHandler(); // 给Qt安装消息处理函数 - void uninstallMessageHandler(); // 取消安装消息处理函数并释放资源 + // 安装全局消息处理器并打开当天日志;目录或文件不可用时返回 false,调用者可在恢复权限后重试。 + bool installMessageHandler(); + + // 卸载当前处理器、恢复安装前的处理器并同步落盘;重复调用不会影响已经恢复后的状态。 + void uninstallMessageHandler(); + + // 返回本库是否仍持有并安装了 Qt 全局消息处理器,供宿主避免重复管理生命周期。 + bool isInstalled() const; + + // 返回进程内唯一实例;C++11 局部静态对象保证首次构造线程安全。 static LogHandler& Get() { static LogHandler m_logHandler; @@ -15,7 +39,25 @@ public: } private: - LogHandler() {}; + // 禁止外部构造,确保文件句柄和 Qt 全局 handler 只由唯一实例管理。 + LogHandler() = default; }; +#if defined(MYLOGGER_TESTING) +// 仅测试构建可见的访问类,用于把文件系统和分卷阈值隔离到临时目录; +// 正式库不定义该宏,因此不会向宿主程序暴露运行时配置接口。 +class LogHandlerTestAccess +{ +public: + // 设置测试专用日志目录;调用前必须卸载 handler,避免正在写入的文件被切换。 + static void setLogDirectory(const QString &directory); + + // 覆盖单文件分卷阈值;传入非正数时恢复正式库的 10 MiB 默认值。 + static void setMaxLogFileBytes(qint64 bytes); + + // 清除测试覆盖项,恢复正式库默认路径和分卷阈值。 + static void reset(); +}; +#endif + #endif // LOGHANDLER_H diff --git a/tests/MyLoggerTests.pro b/tests/MyLoggerTests.pro new file mode 100644 index 0000000..66a4870 --- /dev/null +++ b/tests/MyLoggerTests.pro @@ -0,0 +1,13 @@ +QT += core testlib +TEMPLATE = app +TARGET = MyLoggerTests + +# 测试直接编译被测源码,并显式开启测试访问宏;不会链接或改写正式 MyLogger 动态库。 +CONFIG += testcase console c++17 +DEFINES += MYLOGGER_STATIC MYLOGGER_TESTING QT_MESSAGELOGCONTEXT +INCLUDEPATH += $$PWD/.. + +HEADERS += $$PWD/../loghandler.h +SOURCES += \ + $$PWD/tst_loghandler.cpp \ + $$PWD/../loghandler.cpp diff --git a/tests/tst_loghandler.cpp b/tests/tst_loghandler.cpp new file mode 100644 index 0000000..f05a5d8 --- /dev/null +++ b/tests/tst_loghandler.cpp @@ -0,0 +1,262 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "loghandler.h" + +namespace +{ +std::atomic_int g_previousHandlerCalls{0}; + +// 测试替代 handler 只计数、不调用 Qt 日志 API,避免断言旧 handler 恢复时自身触发递归输出。 +void previousHandler(QtMsgType, const QMessageLogContext &, const QString &) +{ + ++g_previousHandlerCalls; +} +} + +// MyLogger 的 QtTest 用例类:每个用例使用独立临时目录和独立全局 handler, +// 使进程级 qInstallMessageHandler 状态不会跨用例泄漏或污染开发机实际 logs 目录。 +class LogHandlerTest : public QObject +{ + Q_OBJECT + +private slots: + // 套件开始前保存测试进程原有 handler,结束后恢复,避免 QtTest 宿主的日志策略被本模块测试改变。 + void initTestCase(); + void cleanupTestCase(); + + // 每条用例前后重置全局 logger、测试覆盖和临时目录,确保用例可独立重复运行。 + void init(); + void cleanup(); + + // 覆盖安装成功、重复安装、重复卸载以及安装状态查询。 + void installAndUninstallAreIdempotent(); + + // 覆盖日志目录不可用后的失败返回及恢复目录后可重新安装。 + void installFailureCanBeRetried(); + + // 覆盖安装期间接管输出、卸载后恢复调用前 Qt handler 的公共库契约。 + void previousHandlerIsRestored(); + + // 覆盖 UTF-8 内容、等级和源文件信息已实际写入文件。 + void writesUtf8MessageToFile(); + + // 覆盖多线程写入不会死锁,且每个线程的固定标识都能完整落盘。 + void writesFromMultipleThreads(); + + // 覆盖达到测试阈值后产生连续分卷文件。 + void rotatesAtConfiguredSize(); + + // 覆盖只清理由模块命名规则匹配且超过保留期的日志,不触碰宿主文件。 + void removesOnlyExpiredOwnedLogs(); + +private: + // 返回临时目录内模块生成的日志内容,用于在 qCritical 强制 flush 后做精确断言。 + QString readAllLogs() const; + + // 清理前一条用例留下的文件,使当前用例的文件数量和分卷序号只反映自身行为。 + void clearTestLogDirectory(); + + // 保存测试进程原始 handler;临时目录由每条用例创建,生命周期覆盖断言和 cleanup。 + QtMessageHandler m_originalHandler = nullptr; + QTemporaryDir m_logDirectory; +}; + +// 套件开始时保存 QtTest 进程原有 handler,后续用例可自由替换而不影响测试进程退出后的默认行为。 +void LogHandlerTest::initTestCase() +{ + m_originalHandler = qInstallMessageHandler(nullptr); + qInstallMessageHandler(m_originalHandler); +} + +// 套件结束时先确保 logger 已释放文件句柄,再清除测试覆盖并恢复原始 handler。 +void LogHandlerTest::cleanupTestCase() +{ + LogHandler::Get().uninstallMessageHandler(); + LogHandlerTestAccess::reset(); + qInstallMessageHandler(m_originalHandler); +} + +// 每条测试开始前创建干净的目录和替代 handler,防止进程级状态从前序用例泄漏。 +void LogHandlerTest::init() +{ + QVERIFY(m_logDirectory.isValid()); + LogHandler::Get().uninstallMessageHandler(); + LogHandlerTestAccess::reset(); + clearTestLogDirectory(); + LogHandlerTestAccess::setLogDirectory(m_logDirectory.path()); + qInstallMessageHandler(previousHandler); + g_previousHandlerCalls = 0; +} + +// 每条测试结束后恢复运行时默认状态,保证 QtTest 自身和下一条用例不再受 MyLogger 接管。 +void LogHandlerTest::cleanup() +{ + LogHandler::Get().uninstallMessageHandler(); + LogHandlerTestAccess::reset(); + qInstallMessageHandler(m_originalHandler); +} + +// 验证安装与卸载均幂等,重复调用不会留下错误状态或引发全局 handler 竞争。 +void LogHandlerTest::installAndUninstallAreIdempotent() +{ + QVERIFY(LogHandler::Get().installMessageHandler()); + QVERIFY(LogHandler::Get().isInstalled()); + QVERIFY(LogHandler::Get().installMessageHandler()); + + LogHandler::Get().uninstallMessageHandler(); + QVERIFY(!LogHandler::Get().isInstalled()); + LogHandler::Get().uninstallMessageHandler(); +} + +// 用普通文件冒充目录模拟路径不可用,随后切回临时目录验证失败不会永久锁死安装状态。 +void LogHandlerTest::installFailureCanBeRetried() +{ + const QString blockedPath = m_logDirectory.filePath(QStringLiteral("not-a-directory")); + QFile blockedFile(blockedPath); + QVERIFY(blockedFile.open(QIODevice::WriteOnly)); + blockedFile.close(); + + LogHandlerTestAccess::setLogDirectory(blockedPath); + QVERIFY(!LogHandler::Get().installMessageHandler()); + QVERIFY(!LogHandler::Get().isInstalled()); + + LogHandlerTestAccess::setLogDirectory(m_logDirectory.path()); + QVERIFY(LogHandler::Get().installMessageHandler()); +} + +// 验证安装期间旧 handler 不接收消息,而卸载后立即恢复为当前 Qt 全局处理器。 +void LogHandlerTest::previousHandlerIsRestored() +{ + QVERIFY(LogHandler::Get().installMessageHandler()); + qInfo() << "while-mylogger-is-installed"; + QCOMPARE(g_previousHandlerCalls.load(), 0); + + LogHandler::Get().uninstallMessageHandler(); + qInfo() << "after-mylogger-is-uninstalled"; + QCOMPARE(g_previousHandlerCalls.load(), 1); +} + +// 验证 qCritical 立即 flush 后,日志文件保留 UTF-8 中文、等级和源文件上下文。 +void LogHandlerTest::writesUtf8MessageToFile() +{ + QVERIFY(LogHandler::Get().installMessageHandler()); + qCritical() << QStringLiteral("中文 UTF-8 日志标记"); + + const QString content = readAllLogs(); + QVERIFY(content.contains(QStringLiteral("ERROR"))); + QVERIFY(content.contains(QStringLiteral("中文 UTF-8 日志标记"))); + QVERIFY(content.contains(QStringLiteral("tst_loghandler.cpp"))); +} + +// 用多个标准线程并发输出固定标识,验证互斥写盘不会死锁或混淆消息内容。 +void LogHandlerTest::writesFromMultipleThreads() +{ + QVERIFY(LogHandler::Get().installMessageHandler()); + constexpr int threadCount = 4; + constexpr int messagesPerThread = 40; + std::vector workers; + workers.reserve(threadCount); + + for (int threadIndex = 0; threadIndex < threadCount; ++threadIndex) + { + workers.emplace_back([threadIndex]() + { + for (int messageIndex = 0; messageIndex < messagesPerThread; ++messageIndex) + { + qInfo().noquote() << QStringLiteral("worker-%1-message-%2").arg(threadIndex).arg(messageIndex); + } + }); + } + for (std::thread &worker : workers) + { + worker.join(); + } + + qCritical() << "flush-concurrent-log"; + const QString content = readAllLogs(); + for (int threadIndex = 0; threadIndex < threadCount; ++threadIndex) + { + for (int messageIndex = 0; messageIndex < messagesPerThread; ++messageIndex) + { + QVERIFY(content.contains(QStringLiteral("worker-%1-message-%2").arg(threadIndex).arg(messageIndex))); + } + } +} + +// 通过测试专用的小阈值触发多次轮转,并确认连续的第一、第二分卷均被创建。 +void LogHandlerTest::rotatesAtConfiguredSize() +{ + LogHandlerTestAccess::setMaxLogFileBytes(1024); + QVERIFY(LogHandler::Get().installMessageHandler()); + const QString payload(300, QLatin1Char('x')); + for (int index = 0; index < 10; ++index) + { + qInfo().noquote() << QStringLiteral("rotate-%1-%2").arg(index).arg(payload); + } + qCritical() << "flush-rotated-log"; + + const QStringList files = QDir(m_logDirectory.path()).entryList(QStringList() << QStringLiteral("*.log"), QDir::Files); + QVERIFY(files.size() >= 2); + QVERIFY(!files.filter(QRegularExpression(QStringLiteral("_001\\.log$"))).isEmpty()); + QVERIFY(!files.filter(QRegularExpression(QStringLiteral("_002\\.log$"))).isEmpty()); +} + +// 验证清理策略只处理超过保留期且符合 MyLogger 命名约定的文件,宿主业务日志必须保留。 +void LogHandlerTest::removesOnlyExpiredOwnedLogs() +{ + const QString oldOwnedName = QStringLiteral("%1_%2_001.log") + .arg(QDate::currentDate().addDays(-31).toString(QStringLiteral("yyyy-MM-dd"))) + .arg(QCoreApplication::applicationPid()); + const QString oldOwnedPath = m_logDirectory.filePath(oldOwnedName); + const QString hostLogPath = m_logDirectory.filePath(QStringLiteral("host-business.log")); + QFile oldOwnedFile(oldOwnedPath); + QVERIFY(oldOwnedFile.open(QIODevice::WriteOnly)); + oldOwnedFile.close(); + QFile hostLogFile(hostLogPath); + QVERIFY(hostLogFile.open(QIODevice::WriteOnly)); + hostLogFile.close(); + + QVERIFY(LogHandler::Get().installMessageHandler()); + QVERIFY(!QFileInfo::exists(oldOwnedPath)); + QVERIFY(QFileInfo::exists(hostLogPath)); +} + +// 汇总当前用例生成的 UTF-8 日志,避免依赖单一分卷名称或文件系统遍历顺序。 +QString LogHandlerTest::readAllLogs() const +{ + QString content; + const QFileInfoList files = QDir(m_logDirectory.path()).entryInfoList(QStringList() << QStringLiteral("*.log"), QDir::Files); + for (const QFileInfo &fileInfo : files) + { + QFile file(fileInfo.absoluteFilePath()); + if (file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + content += QString::fromUtf8(file.readAll()); + } + } + return content; +} + +// 临时目录仅允许本测试创建普通文件;每条用例前删除它们可消除前序分卷对后续断言的影响。 +void LogHandlerTest::clearTestLogDirectory() +{ + const QFileInfoList entries = QDir(m_logDirectory.path()).entryInfoList(QDir::Files | QDir::NoDotAndDotDot); + for (const QFileInfo &entry : entries) + { + QVERIFY2(QFile::remove(entry.absoluteFilePath()), qPrintable(entry.absoluteFilePath())); + } +} + +QTEST_MAIN(LogHandlerTest) +#include "tst_loghandler.moc"