C# Tips | ファイル・ディレクトリ操作:ファイル監視(FileSystemWatcher)

C sharp C#
スポンサーリンク

はじめに 「ファイル監視」ができると“自動処理”の世界が一気に広がる

業務システムを書いていると、こんな要望がよく出てきます。

フォルダにファイルが置かれたら、自動で取り込んで処理したい。
設定ファイルが書き換えられたら、アプリを再起動せずに反映したい。
ログフォルダに異常なファイルが増えたら、すぐに検知したい。

こういう「ファイルやフォルダの変化をトリガーにして何かしたい」ときに使えるのが、FileSystemWatcher です。
これは .NET が標準で提供している「ファイルシステムの変化を監視するクラス」で、
指定したフォルダに対して「作成」「変更」「削除」「リネーム」などのイベントを受け取ることができます。

ここでは、プログラミング初心者向けに、FileSystemWatcher の基本から、
実務でよくある「監視→自動処理」のパターン、そしてハマりどころまで、かみ砕いて解説していきます。


FileSystemWatcher の基本を理解する

何をしてくれるクラスなのか

FileSystemWatcher は、ざっくり言うと「OS にフォルダを見張ってもらって、変化があったらイベントで教えてもらう」ためのクラスです。
自分で定期的にフォルダをスキャンして差分を取る必要はなく、
OS 側の通知を受け取る形になるので、効率が良く、リアルタイム性も高いです。

監視できる主なイベントは次のようなものです。

Created(ファイル・ディレクトリが作成された)
Changed(内容や属性が変更された)
Deleted(削除された)
Renamed(名前が変更された)

さらに、「どのフォルダを」「どの拡張子を」「サブディレクトリも含めるか」などを細かく指定できます。

最小構成のサンプルコードを先に見る

まずは、動くイメージをつかむために、最小限のサンプルを見てみます。

using System;
using System.IO;

class Program
{
    static void Main()
    {
        string path = @"C:\watch";

        using var watcher = new FileSystemWatcher(path);

        watcher.IncludeSubdirectories = false;
        watcher.Filter = "*.*";

        watcher.NotifyFilter =
            NotifyFilters.FileName |
            NotifyFilters.DirectoryName |
            NotifyFilters.LastWrite;

        watcher.Created += OnCreated;
        watcher.Changed += OnChanged;
        watcher.Deleted += OnDeleted;
        watcher.Renamed += OnRenamed;

        watcher.EnableRaisingEvents = true;

        Console.WriteLine("監視中です。終了するには Enter を押してください。");
        Console.ReadLine();
    }

    private static void OnCreated(object sender, FileSystemEventArgs e)
    {
        Console.WriteLine($"Created: {e.FullPath}");
    }

    private static void OnChanged(object sender, FileSystemEventArgs e)
    {
        Console.WriteLine($"Changed: {e.FullPath}");
    }

    private static void OnDeleted(object sender, FileSystemEventArgs e)
    {
        Console.WriteLine($"Deleted: {e.FullPath}");
    }

    private static void OnRenamed(object sender, RenamedEventArgs e)
    {
        Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
    }
}
C#

このプログラムを動かして C:\watch フォルダでファイルを作ったり消したりすると、
コンソールにイベントが表示されるはずです。
ここから、重要なポイントを一つずつ深掘りしていきます。


監視対象の指定とフィルタリング

Path と Filter と IncludeSubdirectories

FileSystemWatcher のコンストラクタには、監視対象のパスを渡します。

using var watcher = new FileSystemWatcher(path);
C#

ここで指定するのは「フォルダ」です。
個別のファイルではなく、「このフォルダの中で起きる変化」を監視します。

Filter プロパティで、監視するファイル名のパターンを指定できます。

watcher.Filter = "*.csv";
C#

こうすると、.csv ファイルだけが対象になります。
"*.*" にしておけば、すべてのファイルが対象です。

IncludeSubdirectories を true にすると、サブフォルダも含めて再帰的に監視します。

watcher.IncludeSubdirectories = true;
C#

業務でよくあるのは、「特定のフォルダ直下だけを監視する」か「サブフォルダも含めて全部監視する」かの二択です。
どちらにするかは、運用イメージから逆算して決めます。

NotifyFilter で「何の変化を監視するか」を絞る

NotifyFilter は、「どの種類の変化を検知するか」をビットフラグで指定するプロパティです。

watcher.NotifyFilter =
    NotifyFilters.FileName |
    NotifyFilters.DirectoryName |
    NotifyFilters.LastWrite;
C#

ここでは、「ファイル名の変更」「ディレクトリ名の変更」「最終更新日時の変更」を監視しています。
あまり細かく考えずに、最初はこの組み合わせで十分です。

Changed イベントは、LastWrite(最終更新)や Size(サイズ)などが変わったときに発生します。
「内容が変わったかどうか」を知りたい場合は、LastWrite を含めておくのが基本です。


イベントハンドラの書き方と注意点

FileSystemEventArgs と RenamedEventArgs の違い

Created、Changed、Deleted は、どれも FileSystemEventHandler という同じ型のイベントで、
ハンドラの引数は FileSystemEventArgs です。

private static void OnCreated(object sender, FileSystemEventArgs e)
{
    Console.WriteLine($"Created: {e.FullPath}");
}
C#

FileSystemEventArgs からは、主に次の情報が取れます。

FullPath(フルパス)
Name(ファイル名のみ)
ChangeType(Created / Changed / Deleted など)

一方、Renamed イベントだけは、RenamedEventHandler という別の型で、
引数は RenamedEventArgs になります。

private static void OnRenamed(object sender, RenamedEventArgs e)
{
    Console.WriteLine($"Renamed: {e.OldFullPath} -> {e.FullPath}");
}
C#

RenamedEventArgs では、OldFullPathOldName が取れるのがポイントです。
「どのファイルがどの名前に変わったか」をログに残したいときに便利です。

イベントは「複数回」飛んでくることがある

初心者が最初にハマりやすいのが、「Changed イベントが何度も飛んでくる」問題です。
ファイルを保存するとき、アプリケーションによっては「一時ファイルを作ってから入れ替える」「複数回書き込む」といった動きをするため、
Changed が連続して発生することがあります。

つまり、「Changed が 1 回来たら 1 回だけ処理される」とは限りません。
実務で使うときは、次のような工夫をよくします。

一定時間内に同じファイルに対するイベントが連続したら、まとめて 1 回だけ処理する。
Created と Changed の両方が飛んでくる前提で、「どちらが来ても同じ処理をする」。

このあたりは少し高度な話になるので、まずは「イベントは必ずしも 1 回とは限らない」という感覚だけ持っておくと十分です。


典型パターン① 受信フォルダに置かれたファイルを自動処理する

イメージ:受信ボックスフォルダを監視して、CSV が来たら取り込む

業務でよくあるのが、「あるフォルダに CSV を置いてもらったら、自動で取り込んで DB に登録する」といったパターンです。
これを FileSystemWatcher で実現してみます。

using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

public sealed class ImportWatcher : IDisposable
{
    private readonly FileSystemWatcher _watcher;

    public ImportWatcher(string watchDirectory)
    {
        _watcher = new FileSystemWatcher(watchDirectory)
        {
            Filter = "*.csv",
            IncludeSubdirectories = false,
            NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite
        };

        _watcher.Created += OnCreated;
        _watcher.Changed += OnChanged;

        _watcher.EnableRaisingEvents = true;
    }

    private void OnCreated(object sender, FileSystemEventArgs e)
    {
        HandleFileAsync(e.FullPath);
    }

    private void OnChanged(object sender, FileSystemEventArgs e)
    {
        HandleFileAsync(e.FullPath);
    }

    private void HandleFileAsync(string path)
    {
        Task.Run(async () =>
        {
            await WaitFileReadyAsync(path);

            Console.WriteLine($"取り込み開始: {path}");

            try
            {
                string text = File.ReadAllText(path, Encoding.UTF8);
                Console.WriteLine($"ファイル長: {text.Length}");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"取り込み失敗: {path} {ex.Message}");
            }
        });
    }

    private static async Task WaitFileReadyAsync(string path)
    {
        for (int i = 0; i < 10; i++)
        {
            try
            {
                using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None);
                return;
            }
            catch (IOException)
            {
            }

            await Task.Delay(500);
        }
    }

    public void Dispose()
    {
        _watcher.Dispose();
    }
}
C#

このクラスを使う側は、例えばこうです。

class Program
{
    static void Main()
    {
        using var watcher = new ImportWatcher(@"C:\import");

        Console.WriteLine("監視中です。Enter で終了します。");
        Console.ReadLine();
    }
}
C#

ここでの重要ポイントを整理していきます。

重要ポイント① 書き込み中のファイルをすぐに開こうとしない

ファイルが「作成された直後」や「書き込み途中」のタイミングで処理を始めると、
まだ書き込みが終わっていなくて IOException が出たり、中途半端な内容を読んでしまったりします。

そこで WaitFileReadyAsync のようなメソッドを用意して、
「一定時間、ファイルが単独で開けるようになるまで待つ」という工夫をしています。

FileShare.None で開けるということは、「他のプロセスがそのファイルを掴んでいない」状態です。
これを目安に、「もう書き込みは終わった」と判断しています。

重要ポイント② イベントハンドラの中で重い処理をしない

HandleFileAsync の中で Task.Run を使っているのは、
イベントハンドラの中で重い処理(ファイル読み込みや DB 書き込みなど)を直接やらないためです。

イベントハンドラは、FileSystemWatcher の内部スレッドから呼ばれます。
ここで時間のかかる処理をしてしまうと、他のイベント処理が詰まったり、アプリ全体のレスポンスに影響したりします。

実務では、「イベントハンドラでは最低限の情報をキューに積むだけにして、実際の処理は別スレッドで行う」という設計がよく使われます。
ここでは簡易的に Task.Run で別タスクに逃がしています。


典型パターン② 設定ファイルの変更を検知して再読み込みする

イメージ:config.json が書き換えられたら設定を再読み込み

もう一つよくあるのが、「アプリを再起動せずに設定を変えたい」という要望です。
設定ファイル(例えば config.json)を監視して、変更されたら再読み込みする、というパターンです。

using System;
using System.IO;
using System.Text.Json;

public sealed class ConfigWatcher<TConfig> : IDisposable where TConfig : class
{
    private readonly string _configPath;
    private readonly FileSystemWatcher _watcher;

    public TConfig Current { get; private set; }

    public ConfigWatcher(string configPath)
    {
        _configPath = configPath;

        Current = LoadConfig();

        string? dir = Path.GetDirectoryName(configPath)
            ?? throw new ArgumentException("パスが不正です。", nameof(configPath));

        string fileName = Path.GetFileName(configPath);

        _watcher = new FileSystemWatcher(dir)
        {
            Filter = fileName,
            NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
        };

        _watcher.Changed += OnChanged;
        _watcher.EnableRaisingEvents = true;
    }

    private void OnChanged(object sender, FileSystemEventArgs e)
    {
        try
        {
            System.Threading.Thread.Sleep(200);

            Current = LoadConfig();
            Console.WriteLine("設定を再読み込みしました。");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"設定の再読み込みに失敗しました: {ex.Message}");
        }
    }

    private TConfig LoadConfig()
    {
        string json = File.ReadAllText(_configPath);
        return JsonSerializer.Deserialize<TConfig>(json)
            ?? throw new InvalidOperationException("設定ファイルの読み込みに失敗しました。");
    }

    public void Dispose()
    {
        _watcher.Dispose();
    }
}
C#

使い方のイメージです。

public sealed class AppConfig
{
    public string ConnectionString { get; set; } = "";
    public int MaxRetry { get; set; }
}

class Program
{
    static void Main()
    {
        using var watcher = new ConfigWatcher<AppConfig>(@"C:\app\config.json");

        Console.WriteLine("現在の設定:");
        Console.WriteLine(watcher.Current.ConnectionString);

        Console.WriteLine("config.json を編集して保存すると、再読み込みされます。");
        Console.ReadLine();
    }
}
C#

ここでのポイントは、「特定のファイル名だけを Filter で監視している」ことと、
「Changed イベントで再読み込みしている」ことです。


FileSystemWatcher のハマりどころと注意点

バッファオーバーフロー(イベント取りこぼし)の可能性

FileSystemWatcher は内部にイベント用のバッファを持っていて、
短時間に大量のファイル操作が行われると、このバッファがあふれてイベントを取りこぼすことがあります。

例えば、「数万ファイルを一気にコピーする」ような操作を監視していると、
全部の Created イベントを受け取れないことがあります。

この問題に対しては、次のような考え方が必要です。

「イベントは“ヒント”であって、絶対ではない」と割り切る。
重要な処理では、定期的にフォルダ全体をスキャンして整合性を取る。

初心者の段階では、「FileSystemWatcher は完璧ではない」という認識だけ持っておけば十分です。

プロセス終了時に Dispose を忘れない

FileSystemWatcher は OS のリソースを使うので、
使い終わったら Dispose するのが基本です。

using を使うか、クラスに持たせる場合は IDisposable を実装して、
アプリ終了時に確実に解放するようにしておきましょう。

UI スレッドとの関係(WinForms / WPF の場合)

ここまでの例はコンソールアプリ前提でしたが、
WinForms や WPF で使う場合、イベントハンドラは UI スレッド以外から呼ばれます。

そのため、イベントハンドラの中で直接 UI を触ると、「別スレッドから UI を触った」例外が出ます。
この場合は、InvokeDispatcher.Invoke を使って、UI スレッドに処理を投げる必要があります。

これは「スレッドと UI」の話になるので、
FileSystemWatcher に限らず、非同期処理全般で意識しておくべきポイントです。


まとめ 「ファイル監視ユーティリティ」で業務フローを自動化する

FileSystemWatcher を使いこなせるようになると、「人がフォルダを見に行く」世界から「ファイルが来たら勝手に動く」世界に一歩進めます。
業務フローの自動化や、外部システムとのゆるい連携にとても相性が良い機能です。

押さえておきたいポイントを整理すると、こうなります。

監視対象のフォルダ、フィルタ(拡張子)、サブディレクトリの有無をきちんと設計する。
Created / Changed / Deleted / Renamed のイベントを使い分けるが、「イベントは複数回飛ぶこともある」と理解しておく。
書き込み中のファイルをすぐに処理しようとせず、「開けるようになるまで待つ」工夫を入れる。
イベントハンドラの中で重い処理をせず、別スレッドやキューに逃がす設計を意識する。
FileSystemWatcher は完璧ではないので、「取りこぼしの可能性がある」前提で重要な処理を設計する。

ここまで理解できれば、
「受信フォルダ監視」「設定ファイルのホットリロード」「ログフォルダの異常検知」など、
実務でよくある“ファイルをトリガーにした自動処理”を、自分の手で組み立てられるようになります。

タイトルとURLをコピーしました