Win PowerShell2.0之PSEventing——.NET事件


PowerShell提供了对所有.NET对象的支持,用其使得订阅事件处理程序成为可能。脚本块能以委托方式传递给.NET对象,问题在于进程中包含多个复杂的调用,如线程同步和垃圾回收。用户可能需要管理对象的生存周期,而遍历所有脚本也未发现事件,因为它已经被作为垃圾回收;另外,当需要有管理多线程中挂起事件的管理机制时,PowerShell未提供任何支持,从而在执行时很容易导致整个Shell崩溃。

缺少对事件的支持似乎是个大漏洞,但事实上部分情况下不需要自己处理这些事件。大部分脚本在短时间内存活、运行或者二者同时发生,在Shell脚本中不必创建事件驱动程序或实现复杂的应用框架。如果必须处理这种类型的问题,则使用C# 或 VB.NET。Shell脚本是各组件间的粘合剂,工作在更高的抽象层次。

有时可以通过从脚本中调用.NET类的事件得到一定的便利,如用脚本监听FileSystemWatcher的对象,当用户从目录中删除文件时可以给出提示。为了使得PowerShell捕获事件,这里引入PSEventing。PSEventing是由Oisin Grehan创建的一个免费开源工具。它由一系列脚本组成,通过将这些脚本以Shell管理单元的形式加载到PowerShell中提供事件操作的支持。此工具通过全局事件队列实现访问事件队列操作的同步,事件被触发后添加到队列中,剩余的处理工作由客户端代码完成。

1 PSEventing

PSEventing是由Oisin Grehan部署在CodePlex网址为http://www.codeplex.com/

PSEventing上的一个免费开源的项目,它以shell管理单元的形式执行,主要提供一些操作事件的cmdlet。目前的最新发行版本是1.1,作者已经提供了统一的安装。

为安装PSEventing,执行已下载的安装包。然后指定安装目录到C:\Program Files\Windows PowerShell Eventing Snap-In\下,安装程序会自动调用InstallUtil.exe安装管理单元。

需要牢记的是安装管理单元只是用PowerShell注册自身,shell启动后不会自动加载它,所以在使用任何新注册的cmdlets前务必将其添加到shell会话中。加载方式是使用Add-PSSnapin命令,注册PSEventing的方法是Add-PSSnapin PSEventing。

使用PSEventing编写脚本需要注意如何知道是否已经加载管理单元,若已加载,则不用再次加载;否则将得到一个错误信息。一种可能的解决方法是使用Get-PSSnapin检查,示例代码片段如下:

if ((Get-PSSnapin PSEventing -ErrorAction SilentlyContinue) -eq $null)
{
Add-PSSnapin PSEventing
}

更为简单且方便的方法是默认关闭错误提示,如Add-PSSnapin PSEventing -ErrorAction SilentlyContinue。无论PSEventing加载与否,再次加载可以确认其加载,屏蔽错误信息继续执行脚本,则可避免用户感受到不良的体验。

上面的方法忽略了任何由于未加载管理单元而可能抛出的错误,能够以最小代价实现目标功能。当然一个加载错误可能有多种原因,忽略它不一定是最好的选择,因此使用时要掌握编程方便、用户体验,以及脚本有效性之间的度。大部分情况下Add-PSSnapin会提示重复加载问题,说明之前已经成功加载管理单元,事实上这样做是非常安全的。

2 事件处理机制

.NET中的事件通常以委托(delegate)形式存在,当触发事件时调用这个委托,而PSEventing 也能通过一个脚本块作为事件处理程序。PowerShell的缺乏良好的对脚本的线程支持,从外部代码块触发一个事件可能是很危险的。

PSEventing采用了略微不同的处理方式,即抽象出一个全局事件队列,所有访问事件队列的操作都将被同步来保护程序免受并发相关的问题。队列保存在事件绑定表中,用户可以调用Connect-Event来连接事件。并在事件绑定表中创建一个条目,使用Get-EventBinding cmdlet来查看已绑定的事件,如图1所示。

其中的两个绑定将处理FileSystemWatcher 类型的fsw变量的创建和删除事件,通过Disconnect-EventListener cmdlet可以断开与事件的绑定。

绑定到某个事件并不包括任何特定的回调函数,将会在事件被触发后执行回调。触发事件时它会被添加到队列中,其余的工作留给客户端代码完成。用户可以使用cmdlet的Get-Event查询队列并从中清除事件,它返回PSEvent对象,从中可以获取事件参数及其事件触发者传递的参数。Get-Event可以阻塞执行并等待某个事件被添加到队列中,用其可以不必轮询事件的发生状态。

3 FileSystemWatcher

假设需要监测一个文件夹,删除其中的文件时通知用户。为此使用System.IO.FileSystemWatcher对象订阅删除事件。编写名为“WatchDeletedFiles.ps1”的脚本,其代码如下:

# pseventing 1.1
# filesystemwatcher
Add-PSSnapin PSEventing

$fsw = new-object system.io.filesystemwatcher
$fsw.Path = [io.path]::GetTempPath()
$fsw.EnableRaisingEvents = $true
connect-event fsw changed,deleted -verbose

"watching $($fsw.path)"
"entering loop... Ctrl+C to exit"

$done = $false;
while ($events = @(read-event -wait)) {

		# read-event always returns a collection
		foreach ($event in $events) {	    
			switch ($event.name) {
			    "ctrlc" {
			        "cancelling..."
			        $done = $true
			    }
		    
				"changed" {
				    $event
				    #$done = $true
				}
				
				"deleted" {
					$event
				}
			}
		}
	
		if ($done -eq $true) {
			break
		}
}

其中监测的是系统缓存文件夹,FileSystemWatcher事件处理删除文件。需要强调的是在事件队列中可以有多个事件,如一次操作可以删除多个文件,这就是为什么在Get-Event输出时使用For-EachObject的原因。删除文件是重要的操作,错误删除的数据将很难恢复,因此用亮黄色文字提示用户关注。

在脚本退出前要特别注意清理现场,由于不需要监听事件并在全局事件绑定表中留下垃圾条目,所以需要从事件中断开。此外告诉FileSystemWatcher对象,即fsw变量的引用停止挂起事件。运行这一脚本将阻止控制台后续的会话,直到用户发出Ctrl+C命令终止,再次执行期间则会一直监控删除缓存文件夹的操作。脚本执行结果如图2所示。

4 监视写入系统事件日志的条目

.NET允许用户访问系统事件日志并提供了足够的权限,可以通过作为框架一部分的System.Diagnostics.EventLog类实现。这个类是一个强大的工具,可以用来在事件日志中写入记录,第16章中曾经介绍其使用方法。该类的另一个特性是获取新写入的条目的通知,它提供的EntryWritten事件会在应用程序记录一个事件日志或开启一个不同的应用程序时被触发。这个事件仅被限制在本地计算机中运行,如果用于远程计算机,则事件日志将无效。

作为实例,本节将创建一个脚本WatchEventLog.ps1。当用户试图登录时EntryWritten事件会触发并通知用户,用户将会收到System.Diagnostics. EntryWrittenEventArgs对象,并可通过条目属性$_.Args.Entry 访问新的EventLogEntry对象。此外,当用户只对试图登录的事件感兴趣时可以使用InstanceID来过滤事件。这里查找事件ID为517的记录,简单的方式是在系统事件查看器中查看。最有可能的是有一个以前记录的事件日志,其中包括该实例ID,但是称为“事件ID属性”。这是因为事件ID是过时的名称实例ID属性,图3所示为获取登录事件的一个示例。

WatchEventLog.ps1脚本的代码如下:

Add-PSSnapin PSEventing -ErrorAction SilentlyContinue
$securityLog = New-Object System.
Diagnostics.EventLog "Security"
$securityLog.EnableRaisingEvents = $true
Connect-Event securityLog EntryWritten
$logonAttemptEventInstanceID = 517
Read-Event -wait | `
where { `
$_.Args.Entry.InstanceID -eq $logonAttemptEventInstanceID
} | `
foreach { `
Write-Host -foreground Red `
"Logon attempt at: $($_.Args.Entry.TimeGenerated)"
}
Disconnect-Event securityLog EntryWritten
$securityLog.EnableRaisingEvents = $false

运行脚本并用不同的用户账户登录,执行结果如图4所示。

5 处理WMI事件

WMI是一个管理Windows网络的复杂且功能强大的工具,它提供了通用界面来管理操作系统的设备、服务和应用。PowerShell为查询WMI对象和提取不同的系统组件信息提供了很好的支持。PSEventing可以更进一步,并且处理由WMI检测操作系统环境改变时所触发的事件。来自System.Management的.NET类,尤其是ManagementEventWatcher命名空间中的.NET类是获取这些变化通知的主要方式。ManagementEventWatcher类是普通监听器,需要一个单独的查询对象来指定感兴趣的事件类型。除此之外,这一查询对象可以基于事件源的属性和轮询间隔指定过滤条件。用WQL语言编写的查询与SQL相似,查询WMI系统如同操作数据库表。为了得到一些有用的信息,可以使用WMI事件来获取Windows时间服务(W32Time)的停止时间,示例代码如下:

SELECT *
FROM __InstanceModificationEvent
WITHIN 10
WHERE
TargetInstance ISA 'Win32_Service'
AND TargetInstance.Name = 'W32Time'
AND TargetInstance.State = 'Stopped'

其中从特定的_InstanceModificationEvent表中得到对象,并且仅关注Windows中名为“W32Time”和状态为“停止”的服务。WITHIN操作指定了系统更新的时间间隔,WMI查询是个很耗系统资源的操作。每10秒钟执行一次,这样不会使系统过载。下面是脚本Watch_W32Time.ps1的代码:

Add-PSSnapin PSEventing -ErrorAction SilentlyContinue
$queryString = @'
SELECT *
FROM __InstanceModificationEvent
WITHIN 10
WHERE
TargetInstance ISA 'Win32_Service'
AND TargetInstance.Name = 'W32Time'
AND TargetInstance.State = 'Stopped'
'@
$query = New-Object System.Management.
WQLEventQuery -argumentList $queryString
$watcher = New-Object System.Management.
ManagementEventWatcher($query)
Connect-Event watcher EventArrived
$watcher.Start()
echo "Waiting for the W32Time service to stop..."
Read-Event -wait | `
foreach { `
Write-Host -foreground Red "The W32Time service has stopped!"
}
#cleanup
$watcher.Stop()
Disconnect-Event watcher EventArrived
echo "done"

使用WQL查询字符串创建一个WQLEventQuery对象,然后将查询对象传递到ManagementEventListener对象的构造方法中。需要记住的是如果未调用事件观察对象$watcher的Start方法,则永远无法获得一个事件。获得事件后调用停止方法,以便让事件观察监听更好地清理,然后从EventArrived事件中断开。图5所示为运行和停止W32Time服务后的脚本输出。

前面的WMI脚本非常有用,但也可能非常危险。如果未调用事件观察对象$watcher的Stop方法,会使系统一遍遍地执行查询。多次运行脚本会导致添加事件观察对象,使系统变得非常缓慢,因为大多数的CPU时间将分配给执行查询。完成工作时一定要停止事件观察对象,并使其从活动中断开。

6 检测脚本是否被用户终止

本章的第1个例子中监控文件夹中文件删除的操作,当脚本在监听事件时为了验证按Ctrl+C组合键后是只终止当前脚本,还是会终止当前操作,在调用脚本之后增加Write-Host调用命令,执行结果如图6所示。

可以看到最后并没有输出“Done”的信息,PowerShell终止的不仅是脚本,而且是已键入的命令。由此看出Power-Shell 1.0的版本在处理Ctrl+C组合键存在弱点,这也是PSEventing snap-in提供两个cmdlets,即Start-KeyHandler和Stop-KeyHandler的原因。用其可以编写当用户按下Ctrl+C组合键不会终止的脚本,让脚本支持该组合键的步骤如下:

(1)使用Start-KeyHandler -CaptureCtrlC注册事件处理器。

(2)在其余事件中区分Ctrl+C组合键。

(3)工作完成时通过调用KeyHandler注销事件处理器。

编写一个改良版的文件夹监视脚本,命名为“WatchDeletedFilesCtrlC.ps1”,可以正确地处理Ctrl+C组合键。由于事件的唯一性不很明显,所以这里将该组合键事件命名为“CtrlC”并通过Get-Event返回,代码如下:

Add-PSSnapin pseventing -ErrorAction SilentlyContinue

$fsw = new-object system.io.filesystemwatcher
$fsw.Path = [io.path]::GetTempPath()
$fsw.EnableRaisingEvents = $true
Start-KeyHandler -CaptureCtrlC
connect-event fsw changed,deleted -verbose

"watching $($fsw.path)"
"entering loop... Ctrl+C组合键 to exit"

$done = $false;
while ($events = @(read-event -wait)) {
		# read-event always returns a collection
		foreach ($event in $events) {	    
			switch ($event.name) {
			    "ctrlc" {
			        "cancelling..."
			        $done = $true
			    }
			    
				"changed" {
				    $event
				    #$done = $true
				}
				
				"deleted" {
					$event
				}
			}
		}
	
		if ($done -eq $true) {
			break
		}
}

Stop-KeyHandler
Disconnect-Event fsw deleted
$fsw.EnableRaisingEvents = $false

其中循环遍历事件采用foreach和if语句,在语义上等同于前使用的where cmdlet。当需要根据单个条件过滤事件时,基于where的方法更容易使用。注意在调用Get-Event时使用do-while循环,这是必要的,因为有时Read-Event在Ctrl+C事件后会返回$null值。循环将会忽略该值并开始另一个事件监听操作,该脚本只会在实际删除文件时提示用户。

运行脚本,不会终止会话并在同一行继续执行脚本和命令,如图7所示。

能够看到在按Ctrl+C组合键后只是终止了该脚本的执行,并未影响后续代码的执行。

7 使用脚本块作为事件处理

前面的例子很难处理并区分不同事件,解决这个问题的最好方法是使用eventhandler.ps1脚本,该脚本带有PSEventing及其如下相关函数。

(1)Add-EventHandler($variable, $eventName, $script):附上脚本块作为一个处理程序特定事件。

(2)Remove-EventHandler($variable, $eventName):清除附加的事件处理程序。

(3)show-eventhandlerDo-Events($onlyOnce):将会等待一个或多个事件并处理事件处理器。

扩展文件系统监视程序的脚本并命名为“WatchDeletedCreatedFiles.ps1”,使其可以处理deleted和Created事件。首先两次调用Add-EventHandler函数,每次针对一个事件类型,最后调用Do-Events函数。该脚本的代码如下:

Add-PSSnapin pseventing -ErrorAction SilentlyContinue

#dot-sourcing function library
. "C:\Program Files\Windows PowerShell 
Eventing Snap-In\eventhandler.ps1"

$fsw = new-object system.io.filesystemwatcher
$fsw.Path = [io.path]::GetTempPath()
$fsw.EnableRaisingEvents = $true
connect-event fsw changed,deleted -verbose

$fswVariable = (Get-Variable fsw)

$deletedBlock = {
param ($sender, $args)
out-host -input "file deleted"
format-list -property ChangeType,FullPath -input $args
}
$createdBlock = {
param ($sender, $args)
out-host -input "file created"
format-list -property ChangeType,FullPath -input $args
}
Add-EventHandler $fswVariable Deleted $deletedBlock
Add-EventHandler $fswVariable Created $createdBlock
Do-Events $true
Remove-EventHandler $fswVariable Deleted
Remove-EventHandler $fswVariable Created
$fsw.EnableRaisingEvents = $false

该脚本采用dot-sourcing方式导入脚本,然后使用Format-List cmdlet来显示有关该事件的细节,并使用导入脚本中的Add-EventHandler函数创建和删除事件监听。其中调用不带参数的Do-Events将触发默认行为,即一直等待,直到按下Ctrl+C组合键。如果不想如此,则需要传递一个$true参数Do-Events $true。

Do-Events的最大优点是能够快速创建公用的工具集,这些工具会在后台等待。并在系统中出现事件时给出通知,这样避免了需要不断调用Read-Event的while循环来轮询状态。

运行该脚本,在系统缓存文件夹中创建一个文件。然后删除该文件,系统会在监测到文件在创建和删除过程中的操作,并在执行脚本后添加Write-Host的输出。监听事件情况下按Ctrl+C组合键能看到终止的操作,但是Write-Host输出正常。说明终止操作只针对当前脚本,而不是此时的所有操作,执行结果如图8所示。

8 总 结

PSEventing是以Shell管理单元的形式执行,提供一系列操作事件的cmdlet的开源项目。它抽象出全局事件队列,所有访问事件队列的操作都将被同步来保护程序免受并发相关的问题。通过这个脚本库可以实时自动捕捉事件,并允许用户对捕捉的事件执行相应的处理,从而弥补了PowerShell没有提供内置事件监听的弱点。


发表回复