# filemonitor **Repository Path**: be_luckiest/filemonitor ## Basic Information - **Project Name**: filemonitor - **Description**: windows下的文件目录监控工具,使用c++ Qt实现。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2024-09-25 - **Last Updated**: 2024-09-25 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 文件监控工具 ## 整体结构 ![image-20240813093419185](./assets/image-20240813093419185.png) ## 实现的功能点 1. 用户可以设置监听的文件夹目录路径,可以同时监多个目录。 2. 用户可以设置某个目录监听的暂停,恢复,移除 3. 用户可以对监听的目录进行编辑,修改该目录需要监听的事件类型,忽略的文件后缀,包含的文件后缀,忽略的文件路径,包含的文件路径。 4. 用户可以进行偏好设置,设置监听的事件类型,关注或者忽略的文件后缀和文件路径,并保存到本地文件。当监听一个新的目录时,应用这些配置。 5. 监听到的目录变化日志信息可以实时显示在应用程序界面上,同时保存到数据库中。 6. 监听到用户操作日志信息(比如添加文件夹监控,暂停,移除,恢复等操作)可以实时现显示在应用程序界面上,同时保存到数据库中。 7. 用户可以通过输入文件名或者文件夹名来查询日志,并且可以只查询指定时间段的日志。 8. 用户可以指定事件类型,时间段和关键字查询监听目录变化日志,并将结果显示在应用程序上,用户也可以通过点击表格的时间标题进行时间排序。 9. 用户可以指定时间段查询用户操作日志,也可以通过点击表格按照时间排序。 10. 用户点击关闭程序,程序最小化到托盘中。 11. 当监听到文件事件的时候,有消息弹窗弹出。 12. 右键显示日志的窗口,弹出菜单栏,可以将日志信息保存到指定文件。 13. 右键显示日志的窗口,弹出菜单栏,可以清空所有日志。 14. 右键监听目录窗口,可以对监听的目录进行暂停、恢复、移除、编辑。 ## 核心设计说明 ### 监听目录功能 ​ 监听目录采用异步函数`ReadDirectoryChangesExW`捕捉文件事件,然后在回调函数`DirectoryChangeCallback`中处理事件,生成日志对象,发送给应用程序进行处理。 ```C++ class KDirectoryWatcher : public QObject { Q_OBJECT public: KDirectoryWatcher(QObject* parent = nullptr); ~KDirectoryWatcher(); ... ... private: void watchDirectory(); static void CALLBACK DirectoryChangeCallback(DWORD errorCode, DWORD numberOfBytesTransfered, LPOVERLAPPED m_overlapped); private: struct OVERLAPPED_WITH_POINTER : public OVERLAPPED // 异步的数据结构,存储当前的监听对象的指针 { KDirectoryWatcher* watcher; }; HANDLE m_hDir; OVERLAPPED_WITH_POINTER m_overlapped; char m_buffer[1024]; bool m_watching; bool m_isDirExist; // 文件夹是否存在 QTimer* m_pTimer; // 用于定期检查目录是否存在 KDirListenInfo* m_pDirInfo; static int s_cnt; }; void KDirectoryWatcher::watchDirectory() { if (!ReadDirectoryChangesExW( m_hDir, &m_buffer, sizeof(m_buffer), m_pDirInfo->isIncludeSubFoloder(), // 修改为变量,是否包含子目录监听 FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_ATTRIBUTES | FILE_NOTIFY_CHANGE_SIZE | FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_LAST_ACCESS | FILE_NOTIFY_CHANGE_CREATION | FILE_NOTIFY_CHANGE_SECURITY, NULL, &m_overlapped, DirectoryChangeCallback, ReadDirectoryNotifyInformation)) { qDebug() << "ReadDirectoryChangesExW failed: " << GetLastError(); return; } } void CALLBACK KDirectoryWatcher::DirectoryChangeCallback(DWORD errorCode, DWORD numberOfBytesTransfered, LPOVERLAPPED m_overlapped) { if (errorCode != ERROR_SUCCESS) { qDebug() << "DirectoryChangeCallback error: " << errorCode; return; } KDirectoryWatcher::OVERLAPPED_WITH_POINTER* overlappedWithPointer = reinterpret_cast(m_overlapped); KDirectoryWatcher* watcher = overlappedWithPointer->watcher; if (!watcher || !watcher->m_watching) return; ... ... if (watcher->m_watching) { watcher->watchDirectory(); //循环监听 } } ``` ​ 异步函数性能很好,在1秒1000左右的事件消息是可以完全捕捉,应用程序也不会卡顿,但当一秒产生的文件事件越来越多的时候,异步函数捕捉到的消息会大量丢失,而且主程序也会变得卡顿,究其原因,还是因为异步函数的回调函数的处理过程还是在当前线程中执行,也就是主线程中,这样会导致程序卡顿,捕捉到的消息丢失。 ​ 解决方案:将监听类`KDirectoryWatcher`的对象放到子线程中执行,一个目录对应一个监听类对象,一个监听类对象对应一个线程。然后用一个类管理这些数据。 ### 监听功能的异常处理 #### 监听的目录被重命名、删除或者移动 ​ 当监听的目录不存在的时候,比如重命名,那么程序还是会监听到重命名后的文件夹的事件,但这不合理,所以需要在监听的目录不存在后提醒用户。 ​ 解决方案:使用定时器,每秒检查当前监听的目录是否存在。 ```c++ KDirectoryWatcher::KDirectoryWatcher(QObject* parent) : QObject(parent) , m_hDir(INVALID_HANDLE_VALUE) , m_watching(false) , m_isDirExist(false) , m_pDirInfo(Q_NULLPTR) { ZeroMemory(&m_overlapped, sizeof(m_overlapped)); ZeroMemory(m_buffer, sizeof(m_buffer)); m_overlapped.watcher = this; m_pTimer = new QTimer(this); (void)connect(m_pTimer, &QTimer::timeout, this, &KDirectoryWatcher::checkDirectoryExistence); m_pTimer->start(1000); // 1s检查一次目录是否存在 } void KDirectoryWatcher::checkDirectoryExistence() { if (m_pDirInfo == Q_NULLPTR) return; // 如果之前已经发过一次信号,则返回 if (!m_isDirExist) { //m_pTimer.stop(); // 这边注释掉,目录不存在的删除操作就不会出现访问空指针的问题 return; } QFileInfo dirInfo(m_pDirInfo->getDirPath()); bool exists = dirInfo.exists(); if (!exists) { m_isDirExist = false; emit directoryNotExist(m_pDirInfo->getDirPath()); } } // KFileListenWindow.cpp void KFileListenWindow::onHandleDirectoryNotExist(const QString& filePath) { QMessageBox::warning( this, QStringLiteral("警告"), QString::fromLocal8Bit("%1 目录不存在,即将在表格中删除该监听目录!").arg(filePath) ); // 删除对应的监听目录 int curIndex = m_pWatcherManager->findDirectory(filePath); KDirectoryWatcher* watcher = m_pWatcherManager->getWatcher(curIndex); KDirListenInfo* dirInfo = m_pWatcherManager->getDirListenInfo(curIndex); QThread* curThread = m_pWatcherManager->getThread(curIndex); m_pTableModel->removeRow(curIndex); m_pWatcherManager->removeOneRecord(watcher, dirInfo, curThread); } ``` #### 新增的目录和已经在监听的目录相同或者存在父子目录关系 ​ 当新增的目录和已经在监听的目录相同,则弹出一则消息框,告诉用户该监听目录已经存在了;当新增的目录和已经在监听的目录是父子目录的关系,则告诉用户是父子目录,需要选择其中一个进行监听。 ```c++ void KFileListenWindow::onHandleListenDir(const QString& folderPath) { // 如果是重复添加,则弹出一个对话框,告诉用户已经添加过了 if (m_pWatcherManager->isContainDirectory(folderPath)) { QMessageBox::information(this, QStringLiteral("提示"), QStringLiteral("监听的文件夹已经在监听表格中")); return; } // 如果是父目录和子目录,则弹出一个对话框,让用户进行选择,是替换还是不替换 QString relationStr = m_pWatcherManager->findDirectoryRelations(folderPath); if (!relationStr.isEmpty()) { QMessageBox msgBox; msgBox.setStandardButtons(QMessageBox::Yes | QMessageBox::No); msgBox.setDefaultButton(QMessageBox::Yes); if (relationStr.startsWith(folderPath)) msgBox.setText(QString::fromLocal8Bit("%1 是 %2 的父目录! 是否需要替换?").arg(folderPath).arg(relationStr)); else msgBox.setText(QString::fromLocal8Bit("%1 是 %2 的子目录! 是否需要替换?").arg(folderPath).arg(relationStr)); int ret = msgBox.exec(); if (ret == QMessageBox::Yes) { // 删除相关的监听目录 int curIndex = m_pWatcherManager->findDirectory(relationStr); KDirectoryWatcher* watcher = m_pWatcherManager->getWatcher(curIndex); KDirListenInfo* dirInfo = m_pWatcherManager->getDirListenInfo(curIndex); QThread* curThread = m_pWatcherManager->getThread(curIndex); m_pTableModel->removeRow(curIndex); m_pWatcherManager->removeOneRecord(watcher, dirInfo, curThread); // 发送用户操作日志 handleUserLog(QStringLiteral("移除"), dirInfo->getDirPath()); } else return; } ... ... } ``` #### 监听的目录为日志信息数据库的目录 ​ 当监听的目录为存储日志信息的数据库的目录时,会产生无限循环添加日志信息的问题。该问题是因为当有一个日志信息插入到数据库中时,数据库文件也产生了**修改事件**,然后继续将事件插入到数据库中,然后数据库文件又产生修改事件,一直循环下去。 ​ 解决方案:忽略数据库文件的事件。在`KGlobalData`类中添加一个忽略名单,将数据库文件路径添加到忽略名单中。 ```c++ // KDirectoryWatcher.cpp void CALLBACK KDirectoryWatcher::DirectoryChangeCallback(DWORD errorCode, DWORD numberOfBytesTransfered, LPOVERLAPPED m_overlapped) { ... ... bool filterFlag = false; // 是否过滤掉这条消息 while (pNotify) { KDirListenInfo* curDirinfo = watcher->m_pDirInfo; std::wstring fileName(pNotify->FileName, pNotify->FileNameLength / sizeof(WCHAR)); fileNameList.append(curDirinfo->getDirPath() + "/" + QString::fromStdWString(fileName).replace("\\", "/")); //过滤忽略的文件和文件夹 if ((!curDirinfo->isIncludePath(fileNameList.last()) && curDirinfo->isExcludePath(fileNameList.last())) || (!curDirinfo->isIncludeSuffix(fileNameList.last()) && curDirinfo->isExcludeSuffix(fileNameList.last())) || KGlobalData::getInstance().isIgnoreFile(fileNameList.last())) // 全局数据类的忽略名单 { filterFlag = true; break; } ... ... } } // KSQLiteManager // 打开数据库并与数据库建立连接 bool KSQLiteManager::openSQLiteDb(const QString& dbPath) { m_dbSQLite = QSqlDatabase::addDatabase("QSQLITE");// 添加 QSLITE 数据库驱动 m_dbSQLite.setDatabaseName(dbPath);// 设置数据库名称 bool ok = m_dbSQLite.open();// 打开数据库 if (!ok) { qDebug() << "Failed to open database : " << m_dbSQLite.lastError().text(); return false; } // 获取数据库路径 QFileInfo dbFileInfo(m_dbSQLite.databaseName()); QString absolutePath = dbFileInfo.absoluteFilePath(); // 将数据库路径设为不可以监测的文件 KGlobalData::getInstance().addIgnoreFile(absolutePath); //qDebug() << "Database file path:" << absolutePath; return true; } ``` ### 文件事件日志显示 ​ 文件事件消息会实时显示在`KLogWindow`的`QListView`上,当数据量较小的时候,显示不会卡顿,当数据很大的时候,应用程序卡顿,`QListView`上面将没有内容显示。 ​ 解决方案:在`KLogModel`中添加日志缓存`m_pendingLogs` ,限制添加数据到列表中的频率,设置为每秒3条日志信息,同时限制`QListView`显示的日志条数,设置为最多显示1000条,当日志信息超过1000条后,只显示最新的1000条日志。此外,将`KLogModel`对象移动子线程中,进一步缓解程序的卡顿程度。 ```c++ void KLogModel::onHandleTimeout() { //QMutexLocker locker(&m_mutex); if (!m_pendingLogs.isEmpty()) { // 每秒至多显示3条日志信息 int startIndex = m_logList.size(); int targetPendingIndex = qMin(m_pendingIndex + 3, m_pendingLogs.size()); int newDataNum = targetPendingIndex - m_pendingIndex; if (startIndex == m_maxDisplayLogNum && newDataNum > 0) { // 需要在列表的开头移除指定数量的日志 beginRemoveRows(QModelIndex(), 0, newDataNum-1); int cnt = newDataNum; while (cnt) { m_logList.removeFirst(); --cnt; } endRemoveRows(); startIndex -= newDataNum; } for (; m_pendingIndex < targetPendingIndex; ++m_pendingIndex) m_logList.append(m_pendingLogs.at(m_pendingIndex)->toString()); if (m_pendingIndex == m_pendingLogs.size()) { m_pendingLogs.clear(); // 清空数据 m_pendingIndex = 0; } int endIndex = m_logList.size() - 1; if (startIndex <= endIndex) { // 更新 model 数据 beginInsertRows(QModelIndex(), startIndex, endIndex); endInsertRows(); emit logAdded(); } } } ``` ​ 对于这一个功能,在之后又尝试使用了`QTableView`代替`QListView`,发现`QTableView`的性能在同样的配置操作下,性能远优于`QListView`,可以**一秒插入100条**数据到日志列表中,且插入2万条数据也不会出现卡顿。性能提升的原因没有细究,大概是因为`QListView`的model中需要再次调用对`KListenLog`的`toString()`方法,将`KListenLog`中的数据整合为一个字符串显示到列表中。 ### 日志消息通知 ​ 当有大量的文件操作日志产生的时候,日志消息非常多,需要用一个缓存保存下来,然后定时的弹出一个消息。这里我设置为定时器的定时时间为2500毫秒,消息弹窗停留时间为2000毫秒。为了提升性能,将消息通知类`KLogMessageNotify`放入到子线程中。 ```c++ void KLogMessageNotify::onHandleTimeout() { if (!m_logList.isEmpty()) { if (m_pTrayIcon == Q_NULLPTR) { m_pTrayIcon = new QSystemTrayIcon(this); m_pTrayIcon->setIcon(QIcon(":/icons/circle.svg")); } m_pTrayIcon->show(); m_pTrayIcon->showMessage("FileMonitor Message", m_logList.at(m_logListIndex)->toString(), QSystemTrayIcon::Information, 2000); QTimer::singleShot(2000, m_pTrayIcon, &QSystemTrayIcon::hide); // 2000 毫秒后隐藏托盘图标 ++m_logListIndex; if (m_logListIndex >= m_logList.size()) { m_logListIndex = 0; m_logList.clear(); } } } ``` ### 性能优化 ​ 为了提升程序的性能,缓解一秒万次数据的卡顿程度,将一些耗时的逻辑处理和文件读取操作放入到子线程中处理,如数据库的插入和查询、日志数据保存到本地文件等。 ```c++ //KMainWindow.cpp void KMainWindow::initConnection() { ... ... // 数据库操作放到子线程中 KSQLiteManager::getInstance().moveToThread(m_pLogDBThread); (void)connect(m_pLogDBThread, &QThread::started, &KSQLiteManager::getInstance(), &KSQLiteManager::initDataBase); m_pLogDBThread->start(); // 用户日志 (void)connect(m_pFileListenWindow, &KFileListenWindow::sendUserLog, m_pLogWindow, &KLogWindow::addUserLog); // 日志保存任务放到子线程中 KFileSaveTask::getInstance().moveToThread(m_pFileSaveThread); m_pFileSaveThread->start(); } ``` ### 内存泄漏和优化 ​ 在用vld进行内存泄漏检测的时候,发现用户操作日志对象没有正确的释放。用户日志指针对象在产生之后,会发给数据库`KSQLiteManager`和用户日志窗口`KUserOperationWindow`,由于用户日志窗口中使用`QTableWidget`展示所有的用户日志的,所以没有model管理数据,所以指针不知道该何时释放。 ​ 解决方案:将用户日志设置为普通的对象,不是指针对象;或者用智能指针进行管理。 ## 遇到的问题和解决方案 ### 当1秒产生万条数据的时候,此时点击退出菜单栏,程序会卡顿,等待一段时间才会退出 ​ 由于我把数据库的所有操作放到了子线程中,当每秒产生的数据很多,就会导致数据库的插入操作一直在进行中。又由于数据库对象是单例对象,数据库线程放在`KMainWindow`主窗口中,当程序退出的时候,优先调用主窗口的析构函数。 ```c++ KMainWindow::~KMainWindow() { //KSQLiteManager::getInstance().closeDataBase(); m_pLogDBThread->quit(); m_pLogDBThread->wait(); m_pFileSaveThread->quit(); m_pFileSaveThread->wait(); } ``` `m_pLogDBThread`在发出`quit`信号之后,要等待数据库的插入操作完成才行,所以此时不会往下继续调用`子对象`的析构函数,导致子对象中的监听目录窗口的监听类的对象还在一直监听文件事件,产生的新的数据又将保存到数据库中,此时造成了死循环,只能等待脚本文件执行完,数据库数据插入完之后,才会退出程序。 ​ 解决方案:目前没有想到比较好的解决方案,只有在点击退出菜单栏的时候,立即释放监听类对象停止监听的信号,最大程度减少后续数据的产生。 ## 待优化的地方 - [ ] 如果查询到的数据过多,那么要很久才能显示在`QTableView`上 - [ ] 当有大量消息的时候,关闭应用程序,程序会卡顿 - [ ] 当有大量数据需要插入数据库,此时查询日志,查询日志那个窗口将会卡顿,且有时候会直接程序崩溃。