实时日志查看器






4.98/5 (37投票s)
日志服务第二部分 - 查看器。
引言
在这个由两部分组成的系列文章的第一部分中,我介绍了一个为多模块应用程序执行日志记录的服务。在这一部分,我将介绍一个日志文件查看器,它可以近乎实时地显示日志系统处理的日志消息。
该查看器是一个 MFC MDI 应用程序,它为每个视图提供对日志数据的筛选。可以根据消息日志级别、来源模块、来源机器、来源线程或以上任意组合进行筛选。
基础
应用程序中的每个视图对应一个日志文件。我使用了文档/视图(doc/view)架构,但派生自 CDocument
的类只起占位作用。所有工作都在 CLogFileInstance
类中完成,该类是视图的一个成员变量。
当应用程序启动时,它会弹出一个对话框,让你选择要查看的日志文件。关于这个对话框,稍后详述。
在你选择一个(或多个)日志文件后,MFC 框架会为每个日志文件创建一个 CDocument
和一个 CListView
。视图则会创建一个 CLogFileInstance
实例。
每个 CLogFileInstance
会为当前的日志文件创建两个线程,为旧的日志文件创建一个线程。一个过期的日志文件是指文件名格式为 myproduct.yyyymmdd.log 的文件。当前日志文件的文件名格式为 myproduct.log。第一个线程 LogMonitorThread
监控日志文件,当它发现变化时,会读取变更并创建一个或多个 CLogData
对象,每个对象对应一条新的日志条目。这个线程会为所有日志文件启动,但如果日志文件是过期的,它在读取完文件后会自行终止。这里的假设是过期的日志文件不会再改变。
每个新的 CLogData
实例会被添加到一个 CLogData
实例数组中,并通知第二个线程 LogUpdateThread()
。该线程会注意到新添加到 CLogData
数组中的对象,并在经过筛选后,将它们添加到内嵌在视图中的 ClistCtrl
控件里。
LogUpdateThread
线程会为所有日志文件运行,而不仅仅是当前的日志文件。
控制流程如下:
日志文件
这是由日志服务创建的。如我之前的文章中所述,它遵循一种特定的格式。
LogMonitorThread 线程
这个线程每秒运行一次,这是由 WaitForSingleObject()
调用中的超时参数指定的。它等待的对象是一个事件句柄,用于通知线程退出。大多数时候,该对象不会被触发信号,重要的工作都在 WAIT_TIMEOUT
代码块中完成。循环第一次执行时,超时实际上被设置为零,WaitForSingleObject()
调用会立即返回。WAIT_TIMEOUT
处理程序中的代码会将后续所有循环的等待时间设置为 1000 毫秒。
当等待超时发生时,线程首先检查它所监控的文件是否发生了变化。检测变化很简单:日志服务要么向文件追加了内容,要么没有。所以第一个检查是,文件长度是否改变了?如果没有,我们无事可做,直接返回循环顶部再等一秒。如果文件长度改变了,我们就检查当前文件长度是否小于我们上次知道的长度。(记住,日志服务会在本地午夜时滚动文件)。如果当前文件长度小于我们上次知道的长度,我们会清空 CLogData
数组,并告诉我们的视图,它应该删除当前显示的内容,并从头重新读取文件。
如果文件长度改变了并且大于我们上次知道的长度,那么显然有新的日志条目被添加了。代码会进入一个循环,读取行(以换行符结束(但请见下文)),并创建新的 CLogData
对象,然后将它们添加到数组中。当到达文件末尾时,它会返回到循环顶部再等一秒,并在此之前触发 m_eSignal
事件对象,这会使 LogUpdateThread()
线程运行。
void CLogFileInstance::LogMonitorThread(LPVOID data)
{
{
CLogDataArray *me = (CLogDataArray *) data;
BOOL bStop = FALSE;
CLogData *pLog = (CLogData *) NULL;
int iWait = 0;
ASSERT(me);
ASSERT_KINDOF(CLogDataArray, me);
CString csData;
while (bStop == FALSE)
{
// First time through this loop, we wait 0 milliseconds so that we
// get immediate updates. After the first timeout, we set iWait
// to 1000 milliseconds for subsequent passes through the loop.
switch (WaitForSingleObject(me->m_eStop, iWait))
{
case WAIT_OBJECT_0:
bStop = TRUE;
break;
case WAIT_TIMEOUT:
iWait = 1000;
if (me->m_dwFilePosition == me->m_updateFile.GetLength())
// No change
break;
if (me->m_dwFilePosition > me->m_updateFile.GetLength())
{
// The file has rolled over... (or it's been cleared)
me->m_dwFilePosition = 0;
me->m_view->PostMessage(guiAdvise, CLogViewerView::adviseClearList);
break;
}
me->m_updateFile.Seek(me->m_dwFilePosition, CFile::begin);
while (me->m_bContinue && me->m_updateFile.ReadString(csData))
{
if (isdigit(csData[0]))
{
pLog = new CLogData(csData, me->m_logFilterData);
ASSERT(pLog);
ASSERT_KINDOF(CLogData, pLog);
me->Add(pLog);
}
else if (pLog != (CLogData *) NULL)
pLog->AddText(csData);
else
// If we got here, there's a problem with the log file
// format, but just ignore it and soldier bravely on
;
me->m_dwFilePosition = me->m_updateFile.GetPosition();
}
SetEvent(me->m_eSignal);
}
if (
bStop == FALSE
&&
CLoadLogFilesDlg::IsAged(me->m_updateFile.GetFilePath())
)
// If this log file is aged, there's no need to waste a thread
// monitoring it so we just exit. (In theory, there will be
// no changes to the file).
bStop = TRUE;
}
}
_endthread();
}
在这个线程过程中有几点值得注意。首先是内层循环(WAIT_TIMEOUT
分支内的循环)由一个布尔变量 m_bContinue
控制。这是必要的,以便在线程正在读取非常大的日志文件时可以通知它停止。如果视图想要关闭,它会将 m_bContinue
设置为 FALSE
,然后等待线程句柄。这个布尔值确保了在处理完当前日志文件行的工作后,循环会立即退出。
第二点需要注意的是,我希望日志文件格式是灵活的。仅仅假设一个换行符代表这条日志记录的结束,下一行是新记录的开始,这是不够的。我希望能够记录包含内嵌换行符的错误消息(例如 ODBC 消息),而无需在日志服务或日志客户端代码中修改这些消息。所以我们做了一个简化的假设:如果一行以数字开头,我们就认为它是一条新记录。如果不是,那么这一行是前一条记录消息文本的一部分。
为什么不使用 FindFirstChangeNotification() 或 ReadDirectoryChangesW()?
我曾考虑过使用这两个函数来检测日志文件的变化。它们的问题在于,它们只监视一个目录或目录树的变化。如果我能调用一个只监视单个文件的函数,我就会用它。而实际上,NT 的监视函数只是注意到目录中的变化(尽管可以过滤),然后由用户来判断这个变化通知是否相关。定期打开一个文件并监视其长度要简单得多。我曾让这个软件持续运行 48 小时以上,没有任何更新,任务管理器显示它的累计 CPU 时间为 0 秒。在我看来,这足以证明选择这里介绍的更简单的方法是合理的。
写完这篇文章后,我发现自己想知道上面给出的理由是否充分,然后思考了尝试使用任一 API 的后果。
如果使用单个线程来更新所有被监视的日志文件实例,那么可以使用这些 API。这样做的缺点是,你的线程代码必须处理多个日志文件。嗯,它本来就要处理,但将负载分散到多个线程意味着每个日志文件都能根据线程调度获得 CPU 时间片——将此合并到单个线程中意味着你要么对每个日志文件进行“轮询”,每个日志文件处理一条新记录,要么串行处理每个日志文件,这意味着一个日志文件会以牺牲第二个文件为代价占据主导地位,而第二个文件又会以牺牲第三个文件为代价占据主导地位,以此类推。将负载分散到多个线程意味着你可以免费使用线程调度器。
单线程的替代方案是不正常的。如果每个日志文件都在一个单独的线程上监视,并且你试图使用 FindFirstChangeNotification()
或 ReadDirectoryChangesW()
API,你有两个选择。要么在每个线程的基础上做,要么把它合并到一个线程中。
在每个线程的基础上做,你什么也得不到。还不如像我选择的那样,监视日志文件的长度。或者你可以使用另一个线程来监视所有包含被监视日志文件的文件夹。当那个线程注意到变化时,它会通知处理那个日志文件的线程。同样,你有两个选择:维护一个日志文件和线程之间的映射,以便你只通知那个线程;或者通知所有线程,让它们自己去处理。
只通知一个线程意味着你必须通过一个事件对象来维护从线程到目录的映射。这需要额外的代码进行调试和维护,还有一个更微妙的 bug。如果两个线程正在监视同一目录中的不同日志文件,并且你正在使用 FindFirstChangeNotification()
或 ReadDirectoryChangesW()
API,那么很可能只有一个线程会收到变化通知!另一个线程可能要等上几个小时,直到它自己收到更新通知。
通知所有线程意味着你必须使用一个手动重置事件对象来确保所有线程都能看到通知。这就引出了一个问题:到底由谁来重置那个手动重置对象?我能想到的唯一方法是,让运行 FindFirstChangeNotification()
或 ReadDirectoryChangesW()
API 的线程发出手动重置事件对象的信号,然后等待一个对象数组,每个监控线程一个对象,当每个线程完成工作时,它们会通知监视线程等待的数组中的对象,这意味着需要在监视线程上添加和删除事件对象的代码。你看到这是怎么回事了……
最终,监视文件长度要简单得多。
日志更新线程
现在轮到日志更新线程(Log Update Thread)接管了。这个线程等待两个对象。第一个是与监控线程等待的同一个事件句柄,即通知线程退出的那个。第二个对象是 m_eSignal
事件对象,它通知该线程检查 CLogData
数组中是否有新条目。当发现新条目时,它们会通过向主 UI 线程发送 PostMessage
的方式被添加到 CListView
中。
void CLogFileInstance::LogUpdateThread(LPVOID data)
{
{
CLogDataArray *me = (CLogDataArray *) data;
BOOL bStop = FALSE;
CLogData *pLog;
HANDLE handleArray[2];
ASSERT(me);
ASSERT_KINDOF(CLogDataArray, me);
handleArray[0] = me->m_eStop;
handleArray[1] = me->m_eSignal;
while (bStop == FALSE)
{
switch (WaitForMultipleObjects(2, handleArray, FALSE, INFINITE))
{
case WAIT_OBJECT_0:
bStop = TRUE;
break;
case WAIT_OBJECT_0 + 1:
while (me->m_bContinue && me->m_currentEntry < me->GetSize())
{
pLog = (CLogData *) me->GetAt(me->m_currentEntry++);
ASSERT(pLog);
ASSERT_KINDOF(CLogData, pLog);
pLog->AddToView(me->m_view);
}
if (me->m_bContinue)
me->m_view->PostMessage(guiAdvise, CLogViewerView::adviseEndUpdate);
break;
}
}
}
_endthread();
}
在这个线程过程中,最值得注意的是向视图发送的 PostMessage
。正如 Neville Franks 和 Mike Dimmick 在我发布到 Visual C++ 论坛的帖子回复中指出的那样,当试图接触在不同线程中创建的 Windows 对象时,必须非常小心。我这个线程过程的初稿是将一个指向 CListView
的指针传递给线程,然后线程本身直接操作底层的 ListView
控件。大多数时候它都工作正常,但在尝试关闭应用程序时会随机地导致程序挂起。经过一番苦思冥想后,我采纳了建议,开始使用 PostMessage
。瞧!问题解决了。感谢 Neville 和 Mike。
应用程序启动时,它会使用 RegisterWindowMessage()
API 注册一个自定义的 Windows 消息。注册的消息值存储在一个名为 guiAdvise
的全局 UINT
变量中。这就是用于在 LogUpdateThread()
和视图之间通信的消息。
视图类
列表控件(List Control)当然是以报告模式创建的。视图包含了 guiAdvise
消息的处理程序,它看起来像这样:
LRESULT CLogViewerView::OnAdvise(WPARAM wp, LPARAM lp)
{
CListCtrl& ctl = GetListCtrl();
CLogText *pLog;
switch (wp)
{
case adviseAddItem:
pLog = (CLogText *) lp;
ASSERT(pLog);
ASSERT_KINDOF(CLogText, pLog);
if (pLog->IncludeThis(m_logFilter))
ctl.InsertItem(
LVIF_IMAGE | LVIF_PARAM,
INT_MAX,
LPSTR_TEXTCALLBACK,
0,
0,
pLog->Image(),
LPARAM(pLog)
);
break;
case adviseEndUpdate:
if (ctl.GetSelectedCount() == 0)
ctl.EnsureVisible(ctl.GetItemCount() - 1, FALSE);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_DATE, LVSCW_AUTOSIZE_USEHEADER);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_SEQUENCE, LVSCW_AUTOSIZE_USEHEADER);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_CODE, LVSCW_AUTOSIZE_USEHEADER);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_MODULE, LVSCW_AUTOSIZE_USEHEADER);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_THREAD, LVSCW_AUTOSIZE_USEHEADER);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_MACHINE, LVSCW_AUTOSIZE_USEHEADER);
ctl.SetColumnWidth(LOGVIEWER::COLUMN_MESSAGE, LVSCW_AUTOSIZE_USEHEADER);
if (m_bIsActiveWindow)
MAINFRAME->UpdateDlgBar(m_logFileInstance->m_logFilterData);
break;
case adviseClearList:
m_logFileInstance->RemoveAll();
ctl.DeleteAllItems();
break;
}
return 0;
}
adviseAddItem
子消息向列表控件添加一个项。每个项都有一个关联的图像,文本被设置为 LPSTR_TEXTCALLBACK
,这意味着要显示的文本根本不存储在列表控件中。相反,当列表控件需要绘制文本时,它会回调到我们的视图,请求文本。最后,我们将该项的 itemdata
设置为指向与此项对应的 CLogText
对象的指针。
当 LogUpdateThread()
完成对 CLogData
数组中新记录的检查后,它会向视图发送一个消息 adviseEndUpdate
。当视图收到这个消息时:
- 如果列表控件中没有选中的行,我们会滚动到显示区域的末尾,以确保最后添加的项是可见的。如果有一行被选中,我们假设这是一行用户感兴趣的行,不应该滚动出视野。
- 更新完成后,我们还会自动调整列宽。
为什么是 CLogText
?正如本文前面提到的,这里介绍的代码并不假设文本文件中的一行就对应一条记录。我们希望允许日志消息字符串包含嵌入的换行符,这样我们就不必在将每行写入日志文件之前对其进行预处理。(反正我们能用什么来替换换行符呢?)。
因此,我们的 CLogData
对象(代表一条日志记录)包含了构成一条完整记录所需的所有数据(时间戳、序列号、代码、模块、线程以及消息文本的第一行)。如果消息文本不包含换行符(即文件的下一行以数字开头),那就这样了。然而,如果文件的下一行不是以数字开头,我们就假设我们遇到了一个嵌入的换行符,并且这一行是前一条记录的延续。在这种情况下,我们不需要一条完整记录的开销。相反,我们创建一个 CLogText
对象,它只包含这一行的文本和一个指向前面完整记录(一个 CLogData
对象)的指针,并将新对象添加到父 CLogData
对象的一个 CLogText
对象数组中。
CLogText
是 CLogData
类的基类。该类如下所示:
class CLogText : public CObject
{
friend class CLogData;
DECLARE_DYNAMIC(CLogText);
CLogText();
public:
CLogText(const CLogData *pOwner, LPCTSTR szText);
virtual int Image() const;
virtual BOOL IncludeThis(CLogFilter& pFilter) const;
void RenderText(LVITEM& rItem, CLogFilterData& rFilter);
virtual void FormatForTip(
LPTSTR szBuffer, int iBuffLen,
CLogFilterData& rFilter
)const;
protected:
virtual int Code() const;
virtual int Sequence() const;
virtual LPCTSTR Thread(CLogFilterData& rFilter) const;
virtual LPCTSTR Machine(CLogFilterData& rFilter) const;
virtual LPCTSTR Module(CLogFilterData& rFilter) const;
virtual LPCTSTR TimeStamp() const;
virtual LPCTSTR Text() const;
CLogData *Owner() const;
private:
CLogData *m_pOwner;
CString m_csText;
};
注意,这里有一堆 protected virtual
函数。这些函数在派生类 (CLogData
) 中被复制,并且只通过 RenderText()
函数调用(这就是为什么它们是 protected 的)。视图中请求显示数据的代码将所有对象都视为 CLogText
,让对象自己来处理。基类函数要么返回基类中的数据(m_csText
数据),要么通过 m_pOwner
指针来返回存储在所有者对象中的数据。(实际上,这不完全正确。出于美观考虑,如果对象是 CLogText
,代码会为空白显示除了消息字符串之外的所有数据。我更喜欢只在主记录上看到时间戳等信息,而对于续行记录则显示为空白)。
视图通过 OnGetDispInfo
函数处理来自列表控件的数据请求。代码如下:
void CLogViewerView::OnGetDispInfo(NMHDR* pNMHDR, LRESULT* pResult)
{
LV_DISPINFO *pDispInfo = (LV_DISPINFO *) pNMHDR;
LV_ITEM *pItem = &(pDispInfo)->item;
if (pItem->mask & LVIF_TEXT)
{
CLogText *pLog = (CLogText *) pItem->lParam;
ASSERT(pLog);
ASSERT_KINDOF(CLogText, pLog);
pLog->RenderText(*pItem, m_logFileInstance->m_logFilterData);
}
*pResult = 0;
}
非常简单。该函数检查列表控件是否在请求文本,如果是,则将请求传递给底层的对象。在创建项时,一个指向该对象的指针已经被设置为项的 itemdata
。
过滤
这一切都很好。我们现在有了一个程序,可以读取日志文件,显示它们,绘制漂亮的图标,并且几乎可以立即注意到有新记录被追加。嘿,它甚至聪明到在添加新记录时自动滚动,除非你选中了一条记录(在这种情况下,它会假设你想阅读那条记录而不滚动)。
所以你对查看错误记录感兴趣,但日志级别设置为记录所有内容。信噪比可能是 100 条信息消息对 1 条错误消息(或者更高)。这就是筛选功能发挥作用的地方。
每当读取一条新的日志记录时,它都会被解析成各个部分。我无法想象需要根据时间戳或消息文本(或者序列号)进行筛选,但我完全可以想象需要根据代码、模块、机器或线程进行筛选。根据代码进行筛选很容易。如果代码小于 1000,它就是错误。如果小于 2000 但大于 999,它就是警告,依此类推。
对于其他可筛选的条件,我们需要记录我们所看到的内容。我不想去猜测线程 ID,你也不想。模块也是如此。
筛选详情
CLogFilterData
对象包含三个 CStringArray
。每个数组包含对应于模块名、机器名或 threadID
的唯一 string
。存储在 CLogData
对象中(每条记录)的相应数据实际上是 CLogFilterInstance
对象中数组的偏移量。换句话说,如果我们在日志记录的 Module
字段中看到 'ModuleName
',我们会在 CLogFilterData
对象的 Module
数组中搜索匹配项。如果找到匹配项,我们将相应的索引存储为 CLogdata
对象的一部分;如果没有,我们将 'ModuleName
' 添加到 Module
数组中并返回新的索引。无论哪种方式,ClogData
中的条目都会解析为存储在数组中的 string
。
除了为 CLogData
对象提供查找功能外,筛选数据还用于填充主框架对话框栏上的三个组合框。当用户在视图之间切换时,对话框栏会用从该视图派生的筛选数据进行更新,并且用户的最后选择会被重新选中。
不幸的是,更改筛选条件意味着所有当前显示的数据都必须被丢弃,并且列表控件需要重新填充。如果有一种方法可以在列表控件中设置一个项而不让它显示,我还没找到。
因此,更改筛选条件会导致列表控件被清空,CLogFileInstance
对象中的 m_currentEntry
变量被设置为零,并向日志更新线程发送信号。啊哈,所以这就是为什么它是一个独立的线程!
string
数组中 string
的偏移量是从零开始的。组合框用 string
数组的内容填充,如果 string
数组包含多个条目(即,多于一个模块名、机器名或线程 ID),则会包含一个额外的项目 (Show All)。筛选对话框栏中的代码必须考虑到这个额外的项目。我用 -1
作为信号,告诉筛选代码“在筛选时忽略此项”。
一个 Bug 的剖析
正如我今天(2003年12月9日)付出代价所发现的,将 combobox
的当前选择作为筛选中使用的数据是一个天大的错误。问题出在那个额外的虚拟条目 (Show All) 上。它使索引偏离了 1
。现在这本来不是问题,但有东西告诉我,一个包含两个项目的组合框:
- 全部显示
- 某个条目
是完全错误的。如果只有一个选择,在我看来,combobox
应该只包含那一个选择,并且它应该被自动选中,同时 combobox
应该被禁用。
如果你相信这一点,那么你就会发现自己写的代码会在需要时添加 (Show All) 条目,否则不添加。但之后在某个时候,就需要知道是否已经添加了 (Show All) 条目。你看到这是怎么回事了……
总之,我处理 combobox
的代码是完全错误的,因为我试图将用户的选择与筛选数组的索引混为一谈,并试图处理所有数组条目都应该匹配的情况。这样做退化成了一场试图找出所有选择是哨兵值(-1)的情况,并试图在组合框中选择一个条目时对该值做一些明智的事情的游戏。那条路通向疯狂(以及调试版本中的 MFC ASSERT)。
最终,为每个 combobox
维护两个变量要简单得多。第一个是用户的选择——即 CComboBox::GetCurSel()
返回的值。考虑到这个应用程序维护筛选数据的方式(combobox
中的条目数量除非日志文件被清除,否则永远不会减少),这是一个合理的设计选择。
第二个变量是与所选 combobox
项关联的项数据。该值始终是 string
数组的偏移量。
选择日志文件对话框
我之前在文章中向你展示过这个,并暗示稍后会有更多内容。现在就是“稍后”了。:) 为了刷新你的记忆,这里再展示一次。
该对话框类(CLoadLogFilesDlg
)派生自 CFileDialog
类,并提供了一个自定义对话框模板,该模板被添加到标准通用控件的文件打开对话框中。额外的控件是两个按钮:“加载所有日志文件”和“加载今天所有的日志文件”。这应该很简单。第一个按钮加载目录中任何扩展名为 .log 的文件,第二个按钮应用一个筛选器以确保文件格式为“anytext.log”而不是“anytext.yyyymmdd.log”。
我知道有必要拥有我自己的 string
数组,其中包含使用正常文件打开对话框方法(单击文件并按住 Ctrl 键或 Shift 键选择多个文件)选择的日志文件列表,或者符合自定义按钮所用条件的日志文件列表。
我的第一个问题是,如果点击了其中一个自定义按钮,如何关闭对话框。对话框不想关闭。似乎如果“文件名:”字段为空,对话框就不会用确定按钮关闭。(再次感谢 Neville Franks 告诉我这一点)。我尝试了显而易见的方法,比如向对话框发送一个指定 IDOK
的 WM_COMMAND
消息。没有关闭。再试一次用 IDCANCEL
。还是没有关闭。试了用 EndDialog(IDOK);
。仍然没有关闭。这开始变得有点傻了,所以我用了 spy++,发现自定义对话框模板是作为 CFileDialog
类的子对话框创建的。如果你想从你的自定义消息处理程序向对话框发送消息,你需要将它们发送到父对话框。啊哈!
我仍然有关闭对话框的问题。如果“文件名:”字段为空,你不能向父对话框发送 IDOK
并期望它关闭。但你又不能轻易地让那个字段不为空。你可以通过发送 IDCANCEL
来关闭对话框,但那样你怎么知道用户是否真的选择了一个文件?我的解决方案是重写 DoModal()
函数,并在类的内部代码中做两件事之一。
- 如果用户点击了两个自定义按钮中的任何一个,就用匹配的文件(无论是过期的还是不过期的)填充我自己的
string
数组。 - 如果用户使用文件列表组合框选择文件,然后按下“打开”按钮,就使用标准的
CFileDialog
函数用这些文件填充我自己的string
数组。
这个类对调用者撒了谎!如果用户点击了两个自定义按钮中的任何一个,对话框会被告知在用符合条件的文件填充一个数组后自行取消。如果用户点击了打开按钮但没有选择文件,对话框不会关闭。如果用户选择了一个文件并点击了打开按钮,对话框会在填充 string
数组后返回。
重写的 DoModal
根据 string
数组是否为空,向调用者返回 IDOK
或 IDCANCEL
。无论我们是否对我们所遵循的确切路径撒了谎,这都是期望的结果。
特点
就目前而言,这个程序做了我想要的一切。但你可能希望它做得更多。我可以想象扩展它,以便在遇到特定的错误代码时向某人发送电子邮件通知。(感谢 Paul Perkins 的想法)。
历史
- 版本 1 - 2003年12月5日
- 版本 2 - 2003年12月9日。修复了一个由于测试不充分导致的问题 :-(,即
LogUpdateThread
在对当前日志文件进行初始文件加载后就退出了。重新设计了筛选状态的处理方式(将筛选数据与组合框选择分开)。 - 版本 3 - 2003年12月10日。在为版本2的错误写检讨时,我意识到筛选方案中还有另一个漏洞。具体来说,当一个日志文件滚动时,筛选数据没有被清除,因此,如果查看器在滚动时刻一直运行着,那么与昨天文件相关的筛选条件会出现在今天的查看器中。现已修复。
- 版本 4 - 2003年12月12日。添加了 c-sharp 在下面建议的关于线程是否被终止的额外检查。
- 版本 5 - 2004年1月29日。扩展了关于我为什么没有使用
FindFirstChangeNotification()
或ReadDirectoryChangesW()
API 的解释。 - 版本 6 - 2004年2月25日。采纳了 rnoda(见下文)提出的关于重复行问题的修复建议。完全是我的错,我在高负载条件下测试了这个,掩盖了问题。它在低负载条件下出现了。真倒霉。