seraphyの日記

日記というよりは過去を振り返るときのための単なる備忘録

JavaSE7でファイルを監視する方法

動機

上記Windows(C++)でのファイル監視方法を試したので、その流れで、ついでにJavaSE7以降からサポートされるようになったJavaのファイル監視のAPIを試してみる。

いきなり実験コード (JavaSE7 Update4 on Windows7 64ビット版)

package fwatch_java7;

import java.io.Console;
import java.io.File;
import java.io.IOException;
import java.nio.file.WatchEvent.Kind;
import java.nio.file.*;
import java.util.concurrent.TimeUnit;

/**
 * JavaSE7のファイル監視の実験
 * @author seraphy
 */
public class FWatch_java7 implements Runnable {

    /**
     * 監視先ディレクトリ
     */
    private String dirName;

    /**
     * イベントごとのウェイト時間(秒)
     */
    private int waitCnt;
    
    /**
     * オプションの修飾子
     */
    private WatchEvent.Modifier[] extModifiers;
    
    /**
     * 実行部
     */
    @Override
    @SuppressWarnings({"SleepWhileInLoop", "CallToThreadDumpStack"})
    public void run() {
        try {
            // ファイル監視などの機能は新しいNIO2クラスで拡張されたので
            // 旧File型から、新しいPath型に変換する.
            Path dirPath = new File(dirName).toPath();
            System.out.println(String.format("監視先: %s\n待機時間: %d\n", dirName, waitCnt));

            // ディレクトリが属するファイルシステムを得る
            FileSystem fs = dirPath.getFileSystem();

            // ファイルシステムに対応する監視サービスを構築する.
            // (一つのサービスで複数の監視が可能)
            try (WatchService watcher = fs.newWatchService())
            {
                // ディレクトリに対して監視サービスを登録する.
                WatchKey watchKey = dirPath.register(watcher, new Kind[]{
                    StandardWatchEventKinds.ENTRY_CREATE, // 作成
                    StandardWatchEventKinds.ENTRY_MODIFY, // 変更
                    StandardWatchEventKinds.ENTRY_DELETE, // 削除
                    StandardWatchEventKinds.OVERFLOW},    // 特定不能時
                    extModifiers); // オプションの修飾子、不要ならば空配列

                // 監視が有効であるかぎり、ループする.
                // (監視がcancelされるか、監視サービスが停止した場合はfalseとなる)
                while (watchKey.isValid()) {
                    try{
                        // スレッドの割り込み = 終了要求を判定する.
                        if (Thread.currentThread().isInterrupted()) {
                            throw new InterruptedException();
                        }
                        
                        // ファイル変更イベントが発生するまで待機する.
                        WatchKey detecedtWatchKey = watcher.poll(500, TimeUnit.MILLISECONDS);
                        if (detecedtWatchKey == null) {
                            // タイムアウト
                            System.out.print(".");
                            continue;
                        }
                        System.out.println();

                        // イベント発生元を判定する
                        if (detecedtWatchKey.equals(watchKey)) {
                            // 発生したイベント内容をプリントする.
                            for (WatchEvent event : detecedtWatchKey.pollEvents()) {
                                // 追加・変更・削除対象のファイルを取得する.
                                // (ただし、overflow時などはnullとなることに注意)
                                Path file = (Path) event.context();
                                System.out.println(event.kind() +
                                        ": count=" + event.count() +
                                        ": path=" + file);
                            }
                        }

                        // イベントのハンドリングに時間がかかるケースを
                        // Sleepでエミュレートする.
                        // (この間のファイル変更イベントを取りこぼすか否かを確かめられる)
                        for (int cnt = 0; cnt < waitCnt; cnt++) {
                            System.out.print(String.format("%d/%d...\r", cnt + 1, waitCnt));
                            Thread.sleep(1000);
                        }

                        // イベントの受付を再開する.
                        detecedtWatchKey.reset();
                        
                    } catch (InterruptedException ex) {
                        // スレッドの割り込み = 終了要求なので監視をキャンセルしループを終了する.
                        System.out.println("監視のキャンセル");
                        watchKey.cancel();
                    }
                }
            }
        } catch (RuntimeException | IOException ex) {
            ex.printStackTrace();
        }
        System.out.println("スレッドの終了");
    }
    
    /**
     * 第一引数に監視対象のディレクトリを指定する.
     * @param args the command line arguments
     */
    public static void main(String[] args) throws Exception {
        // 監視先
        String dirName = "C:\\temp";
        // イベントごとのウェイト
        int waitCnt = 0;
        WatchEvent.Modifier[] extModifiers = new WatchEvent.Modifier[0];
        
        // オプションを解析する.
        int mx = args.length;
        for (int idx = 0; idx < mx; idx++) {
            String arg = args[idx];
            if (arg.startsWith("-w:")) {
                waitCnt = Integer.parseInt(arg.substring(3));
            } else if (arg.equals("-r")) {
                extModifiers = new WatchEvent.Modifier[] {
                    // ファイルツリーを監視する非標準の修飾子
                    // ※ Windows以外では正しく機能しない.
                    com.sun.nio.file.ExtendedWatchEventModifier.FILE_TREE
                };
            } else {
                dirName = arg;
                break;
            }
        }
        
        // スレッドの開始
        FWatch_java7 inst = new FWatch_java7();
        inst.dirName = dirName;
        inst.waitCnt = waitCnt;
        inst.extModifiers = extModifiers;
        Thread thread = new Thread(inst);
        thread.start();
        
        // エンターキーが押されるまで実行(コンソールがある場合)
        Console cons = System.console();
        if (cons != null) {
            cons.printf("エンターキーで終了.\n");
            cons.readLine();

            // スレッドへの終了要求と終了待機
            thread.interrupt();
        }

        // スレッド終了まで待機
        thread.join();
        System.out.println("done.");
    }
}

※ ソースはgist: 2623100にも登録しました。

使用方法

起動引数として

  • -w:nnn イベントごとの待機秒数(省略時0)
  • -r サブディレクトリ以下を監視するための非標準修飾子を使用する
  • 監視対象のディレクトリ名

を指定できる。


イベントごとの待機秒数は先の実験例と同様に、イベントを受け取ってから次のイベント待ちに入るまでに時間をあけることで、イベントの取りこぼしなどが発生しないか、確認するためのものである。

説明

JavaSE7から、新しくファイルシステムまわりが強化されており、ファイル監視APIも、その一環である。


ファイル監視は、ファイルシステムに対応する監視サービス(WatchService)を作成し、それに特定のディレクトリを監視させる、という形式となる。


監視サービスは複数のディレクトリをまとめて監視することができ、WatchKeyを通じて、どのディレクトリでの監視イベントであるのかを判定することができるようになっている。


監視イベントとしては

  • 作成
  • 変更
  • 削除
  • オーバーフロー

の4種類がある。

上3つは自明のように思われるが、最後の「オーバーフロー」は、変更があったことは確かだが、どのようなものかは通知できない場合に発せられるものである。(ReadDirectoryChangeWのバッファオーバーフローに相当する。)


監視イベントを受け取った場合、監視イベントには、対象ファイルの個数分のイベントが格納されている。

個々のファイルのイベントは、上記のイベントタイプと、もしオーバーフローしていなければ対象となるファイルの「Path」オブジェクトをContextとして受け取ることができる。(オーバーフローした場合はContextはnullとなる。)

このPath情報は監視対象のディレクトリからの相対パスであるので、絶対パスに変換する場合は Path#resolve() などを用いて変換する必要がある。


また、Countプロパティは、前回のpoll()から、現在のpoll()までの間に同一ファイルに対する同一イベントが何回発生しているか示すものである。

注意点
  1. オーバーフローがありえるので、必ずファイルのパス情報が得られるとは想定できない。
  2. 標準ではサブディレクトリに対する監視を設定する方法が存在しない。ディレクトリごとに個々に監視する必要がある。

実験結果

ウェイト時間0での実行例
C:\dist>java -jar fwatch_java7.jar .
エンターキーで終了.
監視先: .
待機時間: 0

.......
ENTRY_CREATE: count=1: path=新しいテキスト ドキュメント.txt
.........
ENTRY_DELETE: count=1: path=新しいテキスト ドキュメント.txt
ENTRY_CREATE: count=1: path=TEST DOC.txt

ENTRY_MODIFY: count=1: path=TEST DOC.txt
.....
ENTRY_MODIFY: count=2: path=TEST DOC.txt
..............
ENTRY_DELETE: count=1: path=TEST DOC.txt
....
監視のキャンセル
スレッドの終了
done.
ウェイト時間10での実行例(オーバーフロー)

監視イベントの待機中に、大量のファイルをコピーしてバッファオーバーを発生させてみる。

C:\dist>java -jar fwatch_java7.jar -w:10 .
エンターキーで終了.
監視先: .
待機時間: 10

...............
ENTRY_CREATE: count=1: path=新しいテキスト ドキュメント - コピー (34).txt
ENTRY_MODIFY: count=1: path=新しいテキスト ドキュメント - コピー (34).txt
10/10...
OVERFLOW: count=262: path=null
........
監視のキャンセル
スレッドの終了
done.
非標準修飾子ExtendedWatchEventModifier.FILE_TREEを指定した場合

標準ではサブディレクトリ以下のファイルの変更は通知されないが、*1
非標準のExtendedWatchEventModifier.FILE_TREEを指定すればサブディレクトリ以下の変更も感知できる。
(ただし、OSのサポートによるため、Windows以外では正しく動かないようである。)

C:\dist>java -jar FWatch_java7.jar -r .
エンターキーで終了.
監視先: .
待機時間: 0

..........
ENTRY_CREATE: count=1: path=新しいフォルダ
..........
ENTRY_DELETE: count=1: path=新しいフォルダ
ENTRY_CREATE: count=1: path=LEVEL1
.......
ENTRY_CREATE: count=1: path=LEVEL1\新しいフォルダ
ENTRY_MODIFY: count=1: path=LEVEL1
......
ENTRY_DELETE: count=1: path=LEVEL1\新しいフォルダ
ENTRY_CREATE: count=1: path=LEVEL1\LEVEL2
ENTRY_MODIFY: count=1: path=LEVEL1
........
ENTRY_CREATE: count=1: path=LEVEL1\LEVEL2\新しいフォルダ
ENTRY_MODIFY: count=1: path=LEVEL1\LEVEL2
...
ENTRY_DELETE: count=1: path=LEVEL1\LEVEL2\新しいフォルダ
ENTRY_CREATE: count=1: path=LEVEL1\LEVEL2\LEVEL3
ENTRY_MODIFY: count=1: path=LEVEL1\LEVEL2
......
ENTRY_CREATE: count=1: path=LEVEL1\LEVEL2\LEVEL3\新規テキスト ドキュメント.txt
ENTRY_MODIFY: count=1: path=LEVEL1\LEVEL2\LEVEL3
.....
ENTRY_DELETE: count=1: path=LEVEL1\LEVEL2\LEVEL3\新規テキスト ドキュメント.txt
ENTRY_CREATE: count=1: path=LEVEL1\LEVEL2\LEVEL3\FILE1.txt
ENTRY_MODIFY: count=1: path=LEVEL1\LEVEL2\LEVEL3
ENTRY_MODIFY: count=1: path=LEVEL1\LEVEL2\LEVEL3\FILE1.txt
......
監視のキャンセル
スレッドの終了
done.

結論

従来のJavaSE6までではフォルダの監視をするには定期的にフォルダ上のファイルをポーリングする必要があった。

定期的にファイルを走査して変更を判定していたため、走査の間隔が短ければ不要な負荷が高まり、走査の間隔が低ければファイル変更を感知するまでの反応が遅くなる、というジレンマがあった。


だが、JavaSE7以降は、このAPIを使えば、そのような心配は不要のものとなる。

ファイルを頻繁に走査する必要はなくなり、且つ、ファイルが変更された場合は必要な時に直ちにイベントを受け取ることができるようになった。


この点だけをとっても、本APIを使うメリットは非常に大きいと思われる。


ただし、本APIは、ファイルの変更通知である。

  • ファイルが変更されたことを通知するが、変更が完了したかどうかは通知されない。
  • どのファイルが変更されたかは通知されるが、必ず通知されるとは期待できない。


これをもとに、どのファイルが、いつ変更されたのか、などを追跡できるようにするためには、前述のWindows版での仕組みと同様に、事前スキャン・事後スキャンによる差分比較が基本になる点は変わらないだろう。

*1:NTFS等、ディレクトリ上でファイル等が追加されると親ディレクトリのタイムスタンプも変更されるため、直接の子のファイルの追加・削除のタイミングで親ディレクトリの変更が通知されることはある。