适用于 PowerShell 7.0 及以上版本(Windows)
背景
在日常工作和技术运维中,我们经常需要知道某个目录下发生了什么变化:配置文件是否被篡改、日志目录是否有新文件生成、用户文档是否被意外删除。传统的做法是写一个轮询脚本,每隔几秒扫描一次目录,但这种方式既低效又容易遗漏变化。
.NET 提供的 System.IO.FileSystemWatcher 类可以监听文件系统的变更事件,当目标目录中出现文件创建、修改、删除或重命名时,它会立即触发通知。结合 PowerShell 的事件注册机制,我们可以构建出高效、实时的文件系统监控方案——从安全审计到自动构建,再到实时备份,都可以基于这套机制实现。
FileSystemWatcher 基础
FileSystemWatcher 是 .NET 内置的文件系统监控组件。使用时需要指定要监控的目录路径,并配置需要监听的事件类型。下面是最基本的创建和启用方式:
1 2 3 4 5 6 7 8 9 10 11
| $watcher = [System.IO.FileSystemWatcher]::new()
$watcher.Path = "C:\Logs"
$watcher.EnableRaisingEvents = $true
$watcher | Format-List Path, Filter, EnableRaisingEvents
|
1 2 3
| Path : C:\Logs Filter : *.* EnableRaisingEvents : True
|
创建 FileSystemWatcher 后,Path 属性指定了要监控的目录,EnableRaisingEvents 设为 $true 后才会真正开始触发事件。默认情况下 Filter 为 *.*,表示监控所有文件。注意,此时虽然已经启用了事件触发,但我们还没有注册任何事件处理器,所以即使目录发生变化也不会有任何响应。
监控文件创建、修改、删除与重命名
FileSystemWatcher 提供了四类核心事件:Created、Changed、Deleted 和 Renamed。我们可以使用 PowerShell 的 Register-ObjectEvent 来订阅这些事件,并通过 -Action 参数指定事件触发时执行的脚本块。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| $watcher = [System.IO.FileSystemWatcher]::new("C:\Logs") $watcher.EnableRaisingEvents = $true
Register-ObjectEvent -InputObject $watcher -EventName Created -SourceIdentifier "FileCreated" -Action { $name = $Event.SourceEventArgs.Name $fullPath = $Event.SourceEventArgs.FullPath $time = $Event.TimeGenerated.ToString("yyyy-MM-dd HH:mm:ss") Write-Host "[$time] 文件创建: $name ($fullPath)" }
Register-ObjectEvent -InputObject $watcher -EventName Changed -SourceIdentifier "FileChanged" -Action { $name = $Event.SourceEventArgs.Name $changeType = $Event.SourceEventArgs.ChangeType $time = $Event.TimeGenerated.ToString("yyyy-MM-dd HH:mm:ss") Write-Host "[$time] 文件修改: $name ($changeType)" }
Register-ObjectEvent -InputObject $watcher -EventName Deleted -SourceIdentifier "FileDeleted" -Action { $name = $Event.SourceEventArgs.Name $time = $Event.TimeGenerated.ToString("yyyy-MM-dd HH:mm:ss") Write-Host "[$time] 文件删除: $name" }
Register-ObjectEvent -InputObject $watcher -EventName Renamed -SourceIdentifier "FileRenamed" -Action { $oldName = $Event.SourceEventArgs.OldName $newName = $Event.SourceEventArgs.Name $time = $Event.TimeGenerated.ToString("yyyy-MM-dd HH:mm:ss") Write-Host "[$time] 文件重命名: $oldName -> $newName" }
Write-Host "已注册全部事件,正在监控 C:\Logs ..."
|
注册完成后,每当 C:\Logs 目录下发生对应的文件操作,PowerShell 就会在后台执行相应的 Action 脚本块。此时如果我们在另一个终端中操作文件,监控端就会输出对应的日志信息。
1 2 3 4
| [2025-04-24 10:15:32] 文件创建: newfile.txt (C:\Logs\newfile.txt) [2025-04-24 10:15:45] 文件修改: newfile.txt (Changed) [2025-04-24 10:16:01] 文件重命名: newfile.txt -> report.txt [2025-04-24 10:16:20] 文件删除: report.txt
|
需要注意,Changed 事件可能被多次触发——例如用记事本保存文件时,文件的内容和属性可能分别触发一次 Changed 事件。这在后续处理中需要做去重处理。
过滤器与监控范围设置
在实际场景中,我们往往不需要监控目录下的所有文件。FileSystemWatcher 提供了 Filter 和 IncludeSubdirectories 两个属性来精确控制监控范围。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| $watcher = [System.IO.FileSystemWatcher]::new("C:\Project\src")
$watcher.Filter = "*.log"
$watcher.IncludeSubdirectories = $true
$watcher.InternalBufferSize = 65536
$watcher.EnableRaisingEvents = $true
Register-ObjectEvent -InputObject $watcher -EventName Created -SourceIdentifier "LogCreated" -Action { $path = $Event.SourceEventArgs.FullPath $time = $Event.TimeGenerated.ToString("HH:mm:ss.fff") Write-Host "[$time] 新日志: $path" }
Register-ObjectEvent -InputObject $watcher -EventName Changed -SourceIdentifier "LogChanged" -Action { $path = $Event.SourceEventArgs.FullPath $time = $Event.TimeGenerated.ToString("HH:mm:ss.fff") Write-Host "[$time] 日志更新: $path" }
Write-Host "正在监控 C:\Project\src 及子目录中的 *.log 文件..."
|
1 2 3
| [10:20:15.123] 新日志: C:\Project\src\app\debug.log [10:20:15.456] 日志更新: C:\Project\src\app\debug.log [10:21:30.789] 新日志: C:\Project\src\api\access.log
|
Filter 属性只支持简单的通配符模式(如 *.log、*.txt),不支持正则表达式或多个扩展名的组合过滤。如果需要同时监控 .log 和 .txt,可以创建多个 FileSystemWatcher 实例,或者在 Action 中用条件判断自行过滤。InternalBufferSize 的设置很重要——当短时间内文件变更过于频繁时,默认的 8KB 缓冲区可能不够用,导致事件丢失。微软建议将其设置为 4KB 的整数倍,最大可到 64KB。
事件注册处理与生命周期管理
PowerShell 的事件订阅不会自动清理,如果不主动注销,它们会一直占用资源。良好的做法是在脚本中记录所有订阅标识,并在不再需要时统一注销。此外,事件处理器中产生的输出不会直接显示在控制台,而是存储在后台作业中,需要用 Receive-Job 来查看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| $subscriptionIds = @( "FSW_Created", "FSW_Changed", "FSW_Deleted", "FSW_Renamed" )
$watcher = [System.IO.FileSystemWatcher]::new("C:\Config") $watcher.EnableRaisingEvents = $true
$jobs = @() $events = @("Created", "Changed", "Deleted", "Renamed") $ids = $subscriptionIds
for ($i = 0; $i -lt $events.Count; $i++) { $job = Register-ObjectEvent -InputObject $watcher ` -EventName $events[$i] ` -SourceIdentifier $ids[$i] ` -Action { $info = @{ Time = $Event.TimeGenerated Type = $Event.SourceEventArgs.ChangeType Path = $Event.SourceEventArgs.FullPath Name = $Event.SourceEventArgs.Name } [PSCustomObject]$info } $jobs += $job }
Write-Host "已注册 $($events.Count) 个事件订阅"
Get-EventSubscriber | Format-Table SourceIdentifier, SubscriptionId, AutoUnregister
Start-Sleep -Seconds 5 foreach ($job in $jobs) { $results = Receive-Job -Job $job -ErrorAction SilentlyContinue if ($results) { $results | Format-Table Time, Type, Name -AutoSize } }
|
1 2 3 4 5 6 7 8 9 10 11 12
| SourceIdentifier SubscriptionId AutoUnregister
FSW_Created 1 False FSW_Changed 2 False FSW_Deleted 3 False FSW_Renamed 4 False
Time Type Name
2025/4/24 10:30:15 Created appsettings.json 2025/4/24 10:30:22 Changed appsettings.json 2025/4/24 10:31:05 Renamed old_config.json
|
使用完毕后,务必清理订阅和作业,否则会造成内存泄漏:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| foreach ($id in $subscriptionIds) { Unregister-Event -SourceIdentifier $id -ErrorAction SilentlyContinue }
foreach ($job in $jobs) { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue }
$watcher.EnableRaisingEvents = $false $watcher.Dispose()
Write-Host "已清理所有事件订阅和资源"
|
实战:自动备份监控目录
将以上知识点整合起来,我们可以构建一个实用的自动备份脚本。当监控目录中的文件发生变更时,自动将最新版本复制到备份目录中,实现近实时的增量备份。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| $SourcePath = "C:\ImportantDocs" $BackupPath = "D:\Backup\ImportantDocs"
if (-not (Test-Path $BackupPath)) { New-Item -Path $BackupPath -ItemType Directory -Force | Out-Null Write-Host "已创建备份目录: $BackupPath" }
$watcher = [System.IO.FileSystemWatcher]::new($SourcePath) $watcher.IncludeSubdirectories = $true $watcher.InternalBufferSize = 65536 $watcher.EnableRaisingEvents = $true
$recentBackup = @{}
Register-ObjectEvent -InputObject $watcher -EventName Created -SourceIdentifier "Backup_Created" -Action { $sourceFile = $Event.SourceEventArgs.FullPath $now = Get-Date
if ($recentBackup.ContainsKey($sourceFile) -and ($now - $recentBackup[$sourceFile]).TotalSeconds -lt 5) { return } $recentBackup[$sourceFile] = $now
$relativePath = $sourceFile.Substring($SourcePath.Length).TrimStart("\") $targetFile = Join-Path $BackupPath $relativePath
$targetDir = Split-Path $targetFile -Parent if (-not (Test-Path $targetDir)) { New-Item -Path $targetDir -ItemType Directory -Force | Out-Null }
try { Copy-Item -Path $sourceFile -Destination $targetFile -Force $timeStr = $now.ToString("HH:mm:ss") Write-Host "[$timeStr] 备份成功: $relativePath" } catch { Write-Host "[ERROR] 备份失败: $relativePath - $($_.Exception.Message)" } }
Register-ObjectEvent -InputObject $watcher -EventName Changed -SourceIdentifier "Backup_Changed" -Action { $sourceFile = $Event.SourceEventArgs.FullPath $now = Get-Date
if ($recentBackup.ContainsKey($sourceFile) -and ($now - $recentBackup[$sourceFile]).TotalSeconds -lt 5) { return } $recentBackup[$sourceFile] = $now
$relativePath = $sourceFile.Substring($SourcePath.Length).TrimStart("\") $targetFile = Join-Path $BackupPath $relativePath
$targetDir = Split-Path $targetFile -Parent if (-not (Test-Path $targetDir)) { New-Item -Path $targetDir -ItemType Directory -Force | Out-Null }
try { Start-Sleep -Milliseconds 500 Copy-Item -Path $sourceFile -Destination $targetFile -Force $timeStr = $now.ToString("HH:mm:ss") Write-Host "[$timeStr] 增量备份: $relativePath" } catch { Write-Host "[ERROR] 备份失败: $relativePath - $($_.Exception.Message)" } }
Write-Host "自动备份已启动: $SourcePath -> $BackupPath" Write-Host "按 Ctrl+C 停止监控..."
try { while ($true) { Start-Sleep -Seconds 1 } } finally { Unregister-Event -SourceIdentifier "Backup_Created" -ErrorAction SilentlyContinue Unregister-Event -SourceIdentifier "Backup_Changed" -ErrorAction SilentlyContinue Get-Job | Where-Object { $_.Name -match "^Backup_" } | Remove-Job -Force $watcher.EnableRaisingEvents = $false $watcher.Dispose() Write-Host "监控已停止,资源已释放" }
|
1 2 3 4 5 6 7
| 已创建备份目录: D:\Backup\ImportantDocs 自动备份已启动: C:\ImportantDocs -> D:\Backup\ImportantDocs 按 Ctrl+C 停止监控... [10:45:12] 备份成功: report.docx [10:45:38] 增量备份: report.docx [10:46:05] 备份成功: subfolder\notes.txt 监控已停止,资源已释放
|
这个脚本包含了几个实用的设计要点:用哈希表进行去重以避免 Changed 事件的重复触发;在备份修改文件时加入了短暂延迟,确保文件写入完成;在 finally 块中确保资源始终被正确释放。
查看与管理事件订阅
在调试和运维过程中,我们需要随时了解当前有哪些活跃的事件订阅。PowerShell 提供了几个相关命令来管理这些订阅。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Get-EventSubscriber | Format-Table SourceIdentifier, SubscriptionId, EventName -AutoSize
Get-Job | Where-Object { $_.JobStateInfo.State -eq "Running" } | Format-Table Id, Name, State -AutoSize
$job = Get-Job | Where-Object { $_.Name -eq "Backup_Created" } | Select-Object -First 1 if ($job) { Receive-Job -Job $job -Keep }
Get-EventSubscriber | Unregister-Event Get-Job | Remove-Job -Force Write-Host "已清理全部订阅和作业"
|
1 2 3 4 5 6 7 8 9 10 11
| ----------------- -------------- ---------
--- ---- -----
|
在调试阶段,善用 Get-EventSubscriber 和 Get-Job 可以快速定位问题。如果发现某个订阅不再触发事件,首先检查对应的 Job 是否还在运行状态。
注意事项
缓冲区溢出导致事件丢失:当短时间内发生大量文件变更时(如批量编译、解压缩),FileSystemWatcher 的内部缓冲区可能溢出。解决方法是增大 InternalBufferSize(最大 64KB),并在 Action 中做轻量处理以尽快释放缓冲区。
Changed 事件重复触发:许多编辑器保存文件时会同时修改文件内容和属性(大小、写入时间等),导致一次保存触发多次 Changed 事件。建议在 Action 中加入时间窗口去重逻辑(如上面备份脚本中的 5 秒内去重)。
网络路径的可靠性:FileSystemWatcher 监控网络共享路径时,网络中断会导致监控失效且不会自动恢复。生产环境中应在循环中检测连接状态并重建 Watcher。
文件锁定问题:文件刚创建或正在写入时,立即读取可能遇到文件锁定。在 Action 中加入短暂延迟(如 Start-Sleep -Milliseconds 500)可以缓解这个问题。
资源释放:FileSystemWatcher 实现了 IDisposable 接口,使用完毕后必须调用 Dispose() 释放资源,同时用 Unregister-Event 注销事件订阅、用 Remove-Job 清理后台作业,否则会造成内存泄漏。
跨平台限制:FileSystemWatcher 依赖 Windows 的文件系统通知机制,在 Linux/macOS 上的 .NET 虽然也有实现,但行为和可靠性可能有差异。如果需要跨平台方案,可以考虑轮询结合哈希校验的方式。