孤高のアーキテクチャ:数千ファイルの高速リネーム・移動ツールに宿す「真の堅牢性」
レガシーシステムの深部、あるいは野良スクリプトが乱立するファイルサーバーの暗部において、数千・数万単位のファイル群を相手にするバッチ処理ほどエンジニアの精神をすり減らすものはない。
「一部のファイルが別プロセスにロックされているため、処理が途中でアボートした」
「アクセス拒否(UnauthorizedAccessException)をキャッチし損ね、深夜稼働の自動化タスクが沈黙した」
「メモリリークにより、GC(ガベージコレクション)が追いつかずにOutOfMemoryErrorでプロセスが爆発した」
VB.NET(Visual Basic)は、その歴史的背景から「初心者向けの簡易言語」と侮られがちだ。しかし、CLR(共通言語ランタイム)と.NETの型システムを骨の髄まで理解したアーキテクトが扱えば、C#と何ら遜色のない、いや、時としてレガシー連携においてより強靭なシステムを構築できる。
今回は、`System.IO.DirectoryInfo` と `.NET Core / .NET 6+` のモダンなランタイム特性を融合させ、「絶対に止まらない、かつ極限まで高速なファイル一括処理エンジン」の設計思想と実装を紐解く。
—
1. アーキテクチャの要件定義
数千ファイルの処理において、ボトルネックとなるのは「I/Oの待ち時間」と「例外によるプロセスの中断」だ。我々が目指すべきアーキテクチャの要件は以下の3点に集約される。
1. 遅延評価とストリーム処理によるメモリ最適化
`Directory.GetFiles()` のような、全パスの配列を一度にメモリ上に展開する愚行は避ける。`EnumerateFiles()` を用いて遅延評価(Deferred Execution)を行い、メモリフットプリントを最小限に抑える。
2. 多重防御型例外ハンドリング(Resilience & Fault Tolerance)
個別のファイルロックや権限不足でバッチ全体を止めない。エラーは「ログ」という名の戦果として記録し、処理は秒速で次のファイルへ移行する。
3. オブジェクトライフサイクルの厳格な管理
ファイルストリームやハンドルリークを完全に防ぐため、usingステートメント(VB.NETにおける `Using` ブロック)を徹底し、非マネージリソースの即時解放を担保する。
—
2. 実装:堅牢なるファイル一括処理エンジン
以下のコードは、実務の現場でそのまま投入可能な、堅牢性を極めたVB.NETのバッチ処理クラスの骨子である。
Option Strict On
Option Explicit On
Imports System
Imports System.IO
Imports System.Collections.Generic
imports System.Text
Namespace Enterprise.Utilities
”’
”’
Public NotInheritable Class RobustFileBatchProcessor
‘ ログ出力用のスレッドセーフな蓄積バッファ
Private ReadOnly _errorLog As New List(Of String)
Private ReadOnly _lockObject As New Object()
”’
”’
”’ 処理対象のルートディレクトリ
”’ 移動先ディレクトリ
”’ 検索パターン(例: “.dat”)
Public Sub ExecuteBatch(sourceDirectory As String, targetDirectory As String, searchPattern As String)
Dim srcDir As New DirectoryInfo(sourceDirectory)
Dim dstDir As New DirectoryInfo(targetDirectory)
‘ 移動先が存在しない場合は自動生成
If Not dstDir.Exists Then
dstDir.Create()
End If
Console.WriteLine($”[INFO] 処理開始: {srcDir.FullName}”)
‘ 【重要】GetFiles()ではなく EnumerateFiles() を使用し、メモリ消費をO(1)に抑える
Try
For Each fileInfo As FileInfo In srcDir.EnumerateFiles(searchPattern, SearchOption.AllDirectories)
ProcessSingleFile(fileInfo, srcDir, dstDir)
Next
Catch ex As UnauthorizedAccessException
RecordError(“ルートディレクトリの走査権限がありません: ” & ex.Message)
Catch ex As DirectoryNotFoundException
RecordError(“ディレクトリが見つかりません: ” & ex.Message)
End Try
Console.WriteLine($”[INFO] 処理完了。エラー件数: {_errorLog.Count}”)
‘ 必要に応じてエラーログをファイルに出力
FlushLogs(dstDir.FullName)
End Sub
”’
”’
Private Sub ProcessSingleFile(fileInfo As FileInfo, baseSrcDir As DirectoryInfo, baseDstDir As DirectoryInfo)
Try
‘ 1. ファイルロックの事前検知(排他制御の確認)
If IsFileLocked(fileInfo) Then
Throw New IOException(“ファイルが他のプロセスによってロックされています。”)
End If
‘ 2. 新しいファイル名の生成ロジック(例:プレフィックス付与 + タイムスタンプ)
Dim newFileName As String = GenerateNewFileName(fileInfo)
‘ ディレクトリ構造を維持した移動先パスの計算
Dim relativePath As String = Path.GetRelativePath(baseSrcDir.FullName, fileInfo.DirectoryName)
Dim targetSubDir As String = Path.Combine(baseDstDir.FullName, relativePath)
If Not Directory.Exists(targetSubDir) Then
Directory.CreateDirectory(targetSubDir)
End If
Dim destinationPath As String = Path.Combine(targetSubDir, newFileName)
‘ 同名ファイルが存在する場合の衝突回避処理
destinationPath = ResolveNameCollision(destinationPath)
‘ 3. アトミックな移動実行
fileInfo.MoveTo(destinationPath)
Console.WriteLine($”[SUCCESS] 移送完了: {fileInfo.Name} -> {destinationPath}”)
Catch ex As UnauthorizedAccessException
‘ アクセス拒否はバッチを止めず、ログに記録して続行
RecordError($”[Access Denied] ファイル名: {fileInfo.FullName} | 理由: {ex.Message}”)
Catch ex As IOException
‘ ロック中やI/Oエラー
RecordError($”[I/O Error] ファイル名: {fileInfo.FullName} | 理由: {ex.Message}”)
Catch ex As Exception
‘ 予期せぬ例外の捕捉
RecordError($”[Fatal Error] ファイル名: {fileInfo.FullName} | 理由: {ex.Message}”)
End Try
End Sub
”’
”’
Private Function IsFileLocked(fileInfo As FileInfo) As Boolean
Try
‘ 読み取り専用モードでストリームを開き、例外の有無でロックを検知する
Using stream As FileStream = fileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.None)
‘ 開けたらロックされていない
End Using
Catch ex As IOException
Return True
End Try
Return False
End Function
”’
”’
Private Function GenerateNewFileName(fileInfo As FileInfo) As String
Dim timestamp As String = DateTime.Now.ToString(“yyyyMMdd_HHmmss_fff”)
Dim extension As String = fileInfo.Extension
Dim nameWithoutExt As String = Path.GetFileNameWithoutExtension(fileInfo.Name)
‘ 例: “OriginalName_20231024_120000_123.ext”
Return $”{nameWithoutExt}_{timestamp}{extension}”
End Function
”’
”’
Private Function ResolveNameCollision(filePath As String) As String
If Not File.Exists(filePath) Then Return filePath
Dim directory As String = Path.GetDirectoryName(filePath)
Dim fileNameWithoutExt As String = Path.GetFileNameWithoutExtension(filePath)
Dim extension As String = Path.GetExtension(filePath)
Dim counter As Integer = 1
Dim newPath As String
Do
newPath = Path.Combine(directory, $”{fileNameWithoutExt}_({counter}){extension}”)
counter += 1
Loop While File.Exists(newPath)
Return newPath
End Function
”’
”’
Private Sub RecordError(message As String)
SyncLock _lockObject
_errorLog.Add($”{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} – {message}”)
End SyncLock
End Sub
”’
”’
Private Sub FlushLogs(outputDirectory As String)
If _errorLog.Count = 0 Then Return
Dim logPath As String = Path.Combine(outputDirectory, $”BatchErrorLog_{DateTime.Now:yyyyMMdd_HHmmss}.log”)
SyncLock _lockObject
File.WriteAllLines(logPath, _errorLog, Encoding.UTF8)
End SyncLock
End Sub
End Class
End Namespace
—
3. チーフアーキテクトが解説する「要所の思想」
上記のコードに散りばめられた、プロダクション環境を生き抜くための技術的エッセンスをいくつか解説する。
`EnumerateFiles` によるメモリの爆発回避
レガシーなVB6や初期の.NETコードでは `Directory.GetFiles()` が安易に使われてきた。しかし、対象が10万ファイルを超えた瞬間、数メガバイトの文字列配列がLOH(Large Object Heap)を圧迫し、GCの停止時間(Stop-the-World)を引き起こす。
`EnumerateFiles()` は イテレータ(Iterator)パターン を採用しており、ファイルパスを1件ずつ遅延評価でストリーム処理するため、メモリ消費量は常に一定(O(1))に保たれる。
`FileShare.None` による確実なロック検知
ファイルが他のプロセス(例えば、別の業務アプリが書き込み中など)によってロックされている場合、中途半端な状態で `MoveTo` を実行すると例外が発生するか、最悪の場合は破損したファイルが移動先量産される。
`IsFileLocked` メソッド内では、あえて `FileShare.None` でストリームを開こうと試み、例外の発生を検知することで「現在書き込み中・ロック中」のファイルを安全に弾いている。
スレッドセーフティと排他制御
将来的にこのバッチを `Parallel.ForEach` などを用いてマルチスレッド化する拡張性を考慮し、エラーログのリスト操作には `SyncLock`(C#の `lock` に相当)を適用している。これにより、複数スレッドからの競合による `ArgumentException` やデータ破損を未然に防いでいる。
—
4. レガシー環境からの脱却とシステム間連携への応用
このエンジンをベースに、さらに高度なシステム間連携へ拡張する場合の指針を述べる。
- トランザクション的思考の導入
ファイルシステム自体は完全なACIDトランザクションをサポートしていない。そのため、万が一バッチ途中で電源断や強制終了が発生した場合の「リカバリ(ロールバック)機構」として、処理済みのファイルを記録するジャーナルファイル(SQLiteや軽量なJSON)を併用するのがシニアの作法だ。
- Win32 APIの活用(超大規模パスの克服)
Windowsの標準APIは、パス長が260文字(MAX_PATH)を超えるファイル群を処理しようとすると無慈悲に例外を吐く。これを回避するためには、マニフェストでの長パス有効化に加え、必要に応じて `\\?\` プレフィックスを付与した低水準のWin32 API(`MoveFileEx` 等)をP/Invokeで叩く設計が求められる。そこまで踏み込むことで、真の「死角のない自動化ツール」が完成する。
—
結びにかえて
Visual Basicは、その直感的な構文の裏で、.NET Frameworkおよび.NET Coreの強力な機能群を何一つ妥協することなく引き出せる言語である。
「動けばいい」という妥協を捨て、メモリ、例外、そしてOSの挙動までを掌握したコードを書くこと。それこそが、レガシーとモダンを繋ぐ現場において、エンジニアが手に入えるべき真の武器となる。
あなたの書くコードが、夜間の静寂なサーバー室で、誰にも気づかれることなく完璧にタスクを遂行し続けることを願う。
