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
+32 -5
View File
@@ -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
)
# 同时识别 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 $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>)
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()
+8 -6
View File
@@ -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
+5 -10
View File
@@ -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
+36 -45
View File
@@ -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 <QApplication>
//头文件
`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` 执行同一套用例。
+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
+48 -6
View File
@@ -1,13 +1,37 @@
#ifndef LOGHANDLER_H
#ifndef LOGHANDLER_H
#define LOGHANDLER_H
#include <QObject>
#include <QtCore/qglobal.h>
class Q_DECL_EXPORT LogHandler
#if defined(MYLOGGER_TESTING)
#include <QString>
#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
+13
View File
@@ -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
+262
View File
@@ -0,0 +1,262 @@
#include <atomic>
#include <thread>
#include <vector>
#include <QDate>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#include <QTemporaryDir>
#include <QtTest>
#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<std::thread> 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"