【ファイルシステム変化の擬似イベント検知】フォルダ内状態のハッシュ比較による差分検出エンジンの作成
開発現場でよくある要求だ。「指定したフォルダにファイルが置かれたら自動処理したい」。
この要望に対し、素人が真っ先に飛びつくのが WMI(Windows Management Instrumentation)の `__InstanceCreationEvent` クエリを用いた監視だ。
だが、チーフアーキテクトである私から言わせれば、それは悪夢の始まりにすぎない。
WMIのイベントリスナーは、OSのリソースを常に消費し続け、ネットワーク共有フォルダ(UNCパス)や高頻度のI/O環境では平気でイベントを取りこぼし、最悪の場合はメモリリークを引き起こす。実務の現場において、WMI常駐型の監視スクリプトは「地雷」でしかないのだ。
では、どうするか?
答えはシンプルだ。「ポーリング(定期実行)によるハッシュ比較差分検出エンジン」を自作する。
今回は、VBScript(WSH)と FileSystemObject (FSO)、そして Dictionary オブジェクトを極限までチューニングし、軽快かつ堅牢にフォルダの変化(作成・改変・削除)を検知するプロダクションコードを伝授しよう。
—
1. なぜ「ハッシュ比較型ポーリング」なのか?
アーキテクチャ選定の理由は明確だ。
1. ゼロ・リソース消費(常駐しない)
WMIのように常時CPUを専有しない。Windowsのタスクスケジューラから数分おきに呼び出す設計にすることで、システムへの負荷を最小限に抑える。
2. ネットワーク共有フォルダへの耐性
WMIイベントはローカルドライブ以外では機能不全を起こしやすいが、FSOによる走査であれば、SMB/CIFS経由の共有フォルダであっても確実に状態を取得できる。
3. 状態の完全なスナップショット比較
「ファイル名」「更新日時」「サイズ」を結合した文字列のハッシュ(あるいはサニタイズされた値)をキーまたはバリューとして保持し、前回のスナップショットと比較する。これにより、一連の処理の中でどのファイルがどう変化したかを完全に特定できる。
—
2. 堅牢な差分検出エンジンの設計思想
今回のエンジンでは、以下の設計方針を貫く。
- DictionaryオブジェクトによるO(1)の高速ルックアップ
配列の総当り探索($O(N^2)$)は厳禁。VBScriptの `Scripting.Dictionary` を用い、ファイルパスをキーとしてハッシュ値を格納することで、膨大なファイル群であっても一瞬で差分を割り出す。
- 状態のシリアライズ保存
前回実行時の状態をテキストファイル(JSONライク、またはCSV形式)として永続化する。これがないと、スクリプトが再起動した瞬間に全ての既存ファイルが「新規作成」と判定される大惨事に見舞われる。
- ロバストなエラーハンドリング(On Error Resume Nextの適正利用)
ファイルシステムは水物である。走査中に別プロセスがファイルを掴んでアクセス拒否(エラー 70: 書き込み権限がありません / Permission Denied)が発生することは日常茶飯事だ。これをいかにスマートにハンドリングし、処理を止めないかがエンジニアの腕の見せ所だ。
—
3. プロダクションコード:`FileDifferEngine.vbs`
以下のコードは、コピー&ペーストしてパスを書き換えるだけで、即座に実務投入できるプロダクション品質のスクリプトだ。
‘ ==============================================================================
‘ Script Name : FileDifferEngine.vbs
‘ Description : FSOとDictionaryを用いた高速フォルダ差分検出エンジン
‘ Author :Chief Architect
‘ ==============================================================================
Option Explicit
Const TARGET_FOLDER = “C:\CtsData\MonitorTarget” ‘ 監視対象フォルダ
Const STATE_FILE = “C:\CtsData\state_cache.dat” ‘ 状態保存キャッシュファイル
Const LOG_FILE = “C:\CtsData\differ_exec.log” ‘ 実行ログファイル
‘ メイン処理実行
Main
Sub Main()
Dim fso, dictCurrent, dictPrevious
Set fso = CreateObject(“Scripting.FileSystemObject”)
‘ 監視対象の存在確認
If Not fso.FolderExists(TARGET_FOLDER) Then
WriteLog “ERROR: 監視対象フォルダが存在しません – ” & TARGET_FOLDER
Exit Sub
End If
WriteLog “INFO: 差分検出スキャンを開始します。”
‘ 1. 現在のフォルダ状態をスキャンしてDictionaryに格納
Set dictCurrent = CreateObject(“Scripting.Dictionary”)
ScanFolder fso, fso.GetFolder(TARGET_FOLDER), dictCurrent
‘ 2. 前回の状態を読み込み
Set dictPrevious = CreateObject(“Scripting.Dictionary”)
LoadPreviousState fso, dictPrevious
‘ 3. 差分の検出と出力 (新規作成・改変・削除)
DetectChanges dictPrevious, dictCurrent
‘ 4. 現在の状態を次回の比較用に保存
SaveCurrentState fso, dictCurrent
WriteLog “INFO: 差分検出スキャンを終了しました。”
End Sub
‘ フォルダ再帰スキャン関数
Sub ScanFolder(fso, targetFolder, dict)
On Error Resume Next
Dim file, subFolder
‘ ファイル群のスキャン
For Each file in targetFolder.Files
If Err.Number = 0 Then
‘ キー: 相対パス, バリュー: 更新日時_サイズ
Dim relPath
relPath = Mid(file.Path, Len(fso.GetAbsolutePathName(TARGET_FOLDER)) + 2)
dict.Add relPath, FormatDateTime(file.DateLastModified, 0) & “_” & file.Size
End If
Err.Clear
Next
‘ サブフォルダの再帰スキャン
For Each subFolder in targetFolder.SubFolders
If Err.Number = 0 Then
ScanFolder fso, subFolder, dict
End If
Err.Clear
Next
On Error GoTo 0
End Sub
‘ 差分検出ロジック
Sub DetectChanges(dictPrev, dictCurr)
Dim key, keys, prevVal, currVal
‘ — 新規作成および改変の検出 —
keys = dictCurr.Keys
For Each key in keys
If Not dictPrev.Exists(key) Then
‘ プレブに存在しない = 新規作成
WriteLog “[CREATED] ” & key
‘ TODO: ここに新規作成時の業務処理(API連携や別フォルダへの移動など)を記述
Else
‘ 存在する場合は値(更新日時_サイズ)を比較
prevVal = dictPrev(key)
currVal = dictCurr(key)
If prevVal <> currVal Then
WriteLog “[MODIFIED] ” & key
‘ TODO: ここに改変時の業務処理を記述
End If
End If
Next
‘ — 削除の検出 —
keys = dictPrev.Keys
For Each key in keys
If Not dictCurr.Exists(key) Then
‘ カレントに存在しない = 削除された
WriteLog “[DELETED] ” & key
‘ TODO: ここに削除時の業務処理を記述
End If
Next
End Sub
‘ 前回状態のロード
Sub LoadPreviousState(fso, dict)
If Not fso.FileExists(STATE_FILE) Then Exit Sub
Dim ts, line, parts
Set ts = fso.OpenTextFile(STATE_FILE, 1, False) ‘ 1: ForReading
Do While Not ts.AtEndOfStream
line = ts.ReadLine
parts = Split(line, vbTab)
If UBound(parts) = 1 Then
If Not dict.Exists(parts(0)) Then
dict.Add parts(0), parts(1)
End If
End If
Loop
ts.Close
End Sub
‘ 現在状態のセーブ(タブ区切り: パス \t 更新日時_サイズ)
Sub SaveCurrentState(fso, dict)
Dim ts, keys, key, i
Set ts = fso.OpenTextFile(STATE_FILE, 2, True) ‘ 2: ForWriting, True: Create
keys = dict.Keys
For i = 0 To dict.Count – 1
key = keys(i)
ts.WriteLine key & vbTab & dict(key)
Next
ts.Close
End Sub
‘ 共通ログ出力関数
Sub WriteLog(msg)
On Error Resume Next
Dim fso, ts
Set fso = CreateObject(“Scripting.FileSystemObject”)
Set ts = fso.OpenTextFile(LOG_FILE, 8, True) ‘ 8: ForAppending
ts.WriteLine “[” & Now & “] ” & msg
ts.Close
On Error GoTo 0
End Sub
—
4. チーフアーキテクトからの実装上の重要アドバイス
このスクリプトを実務運用するにあたり、以下の3点を心に刻んでおいてほしい。
1. タスクスケジューラとの連携頻度
WMIを使わない代わりに、Windowsの「タスクスケジューラ」を使い、5分おきや15分おきにこのスクリプトをキックする設定を行う。実行時はバックグラウンドで軽快に動くため、ユーザーの業務を阻害しない。
2. 排他制御とロックファイル
もし対象フォルダのファイル容量が極めて大きく、スキャン中に次のタスクスケジュールが起動してしまった場合、`state_cache.dat` の書き込み競合(ファイルロックエラー)が発生する可能性がある。
実運用では、スクリプトの先頭で「自身の多重起動チェック(プロセス名やロック用空ファイルの作成)」を実装するか、タスクスケジューラの重複起動防止設定を「インスタンスを起動しない」に必ず指定すること。
3. 文字コードの罠
VBScriptの `OpenTextFile` はデフォルトで ANSI(Shift-JIS)でファイルを読み書きする。もし監視対象のファイル名に特殊文字や日本語が含まれていて状態ファイル(`state_cache.dat`)が文字化けする場合は、ADODB.Stream オブジェクトを用いて UTF-8 で入出力するように拡張すると完璧だ。
—
総括
「枯れた技術」と侮られがちなVBScriptだが、適切なアルゴリズム(今回はDictionaryを用いたハッシュ比較)とエラーハンドリングを組み合わせれば、モダンな言語で重厚長大な常駐サービスを作るよりも、よほど堅牢で保守性の高いインフラ自動化ツールが完成する。
WMIの不安定さに泣きたくなったら、この「差分検出エンジン」の設計思想を思い出してほしい。シンプルでスマートなコードこそが、現場を救う最強の武器なのだ。
