seraphyの日記

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

ファイルの変更を監視する方法

Win32 APIにはファイルの変更を監視する方法として、少なくとも2つの方法がある。

私は従来よりファイル監視のためのAPIとして、FindFirstChangeNotification関数を愛用している。


だが、WindowsNT系のUNICODEビルドであれば使えるというReadDirectoryChangeW関数によるファイル監視を使うことにより、パフォーマンスの改善が望めそうに思えたため、これを用いた場合には、どのような実装方法となるのか確かめたく実験してみることとした。

FindFirstChangeNotification APIを使ってファイルを監視する方法

Win32でファイルの変更通知を行うものとしては、Windows95時代から使える方法として、FindFirstChangeNotification関数があげられる。

このAPIは、指定したディレクトリまたは、その配下に対してファイルが作成・更新・削除されると、変更されたことを通知する。

どのファイルが変更されたかは通知されない。

実験コード
#include "stdafx.h"

// stdafx.hプリコンパイルヘッダでの定義は以下のとおり
// (XP SP2以降)
//#define _WIN32_WINNT 0x0502
//#include <stdio.h>
//#include <locale.h>
//#include <tchar.h>
//#include <vector>
//#include <conio.h>
//#include <Windows.h>

// エラーの表示
static void ShowError(LPCTSTR msg)
{
	DWORD errcode = GetLastError();
	_tprintf(_T("%s errorcode: %lx\r\n"), msg, errcode);
}

// メインエントリ
int _tmain(int argc, _TCHAR* argv[])
{
	// コンソール出力を日本語可能に
	setlocale(LC_ALL, "");

	// オプション引数の値を保持する
	LPCTSTR pDir = _T("C:\\temp\\fwatch2v");
	int waittime = 0;
	bool hasError = false;

	// 引数の解析
	_TCHAR **pArg = &argv[1];
	while (*pArg) {
		if (_tcsicmp(_T("/w"), *pArg) == 0) {
			// ウェイト時間
			pArg++;
			if (*pArg) {
				waittime = _ttoi(*pArg);
			}

		} else if (**pArg != '/') {
			// 監視先ディレクトリ
			pDir = *pArg;
			break;

		} else {
			_ftprintf(stderr, _T("不明な引数: %s\r\n"), *pArg);
			hasError = true;
		}

		pArg++;
	}
	if (hasError) {
		return 2;
	}

	if (waittime <= 0) {
		waittime = 0;
	}

	_tprintf(_T("監視先: %s\r\nループ毎の待ち時間: %ld\r\n"), pDir, waittime);


	// 監視条件
	DWORD filter = 
		FILE_NOTIFY_CHANGE_FILE_NAME   |  // ファイル名の変更
		FILE_NOTIFY_CHANGE_DIR_NAME    |  // ディレクトリ名の変更
		FILE_NOTIFY_CHANGE_ATTRIBUTES  |  // 属性の変更
		FILE_NOTIFY_CHANGE_SIZE        |  // サイズの変更
		FILE_NOTIFY_CHANGE_LAST_WRITE;    // 最終書き込み日時の変更

	// 対象のディレクトリを監視用にオープンする.
	HANDLE hWatch = FindFirstChangeNotification(
		pDir,  // 監視先ディレクトリ
		TRUE,  // サブディレクトリも監視
		filter // 監視条件
		);
	if (hWatch == INVALID_HANDLE_VALUE) {
		ShowError(_T("FindFirstChangeNotificationでの失敗"));
		return 1;
	}

	// 変更通知を受け取りつづけるループ
	for (;;) {
		// qで終了.
		if (_kbhit()) {
			if (_getch() == 'q') {
				break;
			}
		}

		// 変更通知まち
		DWORD waitResult = WaitForSingleObject(hWatch, 500); // 0.5秒待ち
		if (waitResult == WAIT_TIMEOUT) {
			// 待ち受け表示
			_tprintf(_T("."));
			continue;
		}

		// 変更通知があった場合 (イベントがシグナル状態になった場合)
		_tprintf(_T("\r\n★ファイルが変更されました!★\r\n"));

		// Sleepを使い、イベントハンドリングが遅いケースをエミュレートする.
		// Sleep表示中にファイルを変更した場合に検知漏れが発生しないか確認できる.
		const int mx = waittime * 10;
		for (int idx = 0; idx < mx; idx++) {
			_tprintf(_T("sleep... %d/%d \r"), idx + 1, mx);
			Sleep(100);
		}

		// 監視の継続
		if (!FindNextChangeNotification(hWatch))
		{
			ShowError(_T("FindNextChangeNotificationでの失敗"));
			break;
		}
	}

	// 監視の終了
	FindCloseChangeNotification(hWatch);

	return 0;
}

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


このコンソールプログラムは、引数として

  • /w nnn 待機時間 (省略時は0) (0を指定すると待機なし)
  • 監視先フォルダ

を指定することができる。

説明

プログラムの内容は、次の通り。

  1. FindFirstChangeNotificationで指定したフォルダと監視条件を設定する。
  2. WaitForSingleObject等でイベントが発生するまで待機する
    1. イベントが発生したら、なんらかの変更があったことを意味する
  3. 引き続き監視する場合は、FindNextChangeNotificationを実行し、2に戻る。
    (WaitForSingleObjectでイベントを受け取ってから、FindNextChangeNotification を呼び出すまでの間にファイルの変更があったとしても、次のFindNextChangeNotificationは変更を感知してくれている。)
  4. 終了する場合は、FindCloseChangeNotificationでハンドルを解放する.
ファイル監視のための実用的なロジックにするためには

ただし、このAPIで通知されるのは「変更の有無」だけであり、どのファイルが変更されたかは通知されない。

変更されたファイルを知り、ファイルの変更をイベントとして扱えるようにするには、上記の変更通知だけでは対応できない。


また、変更通知は、変更があった時点で通知される。いつ、変更が完了したのかは通知からはわからない。
そのため、一定時間待って変化がなくなったときが完了したものとみなす、などの処置が必要になる。
(ログファイル等、ファイルによっては開かれっぱなしのものもあるであろうから、このあたりの判定方法はアプリケーション次第といえる。)


よって、一般的には、おそらく以下のようなロジックが必要になるだろう。

  1. まずフォルダを全スキャンしてファイルの一覧を予め作成しておく。
  2. ファイルが変更されたことが通知されるまで待機する。(WaitForMultipleObjects関数などで)
  3. 通知をうけたら再度フォルダを全スキャンする。
  4. 通知の前後でのスキャン内容を照合し、差分を把握する。
    1. 追加されたもの(スキャン前に存在しないもの)
    2. 削除されたもの(1スキャン前にしか存在しないもの)
    3. 変更されたもの(日付、サイズ、属性などが変わったもの)
  5. 差分についてアクションを起こすようにマークするが、まだアクションは起こさない
    1. 最後に変更されてから一定時間変化がなければアクションを実行する
    2. 変更されつづけている場合は、まだアクションは起こさない

※ ちなみに、このロジックを実装した拙作フリーウェア 「fwatch ファイル監視」sourceforge.jpに置いてあります。

FindFirstChangeNotificationの利点

FindFirstChangeNotificationは、変更されたファイルはわからない、という性質をもってはいるが、
実際のアプリケーションにおいては自分が現在開いている、もしくは参照しているフォルダに影響があるかどうかを調べるために
APIを使うことが多いと思われるため、その場合には、かなり単純に使うことができる点で有利と思う。


もし、ファイルを特定する必要があれば前述のように事前スキャン・事後スキャンの差異をとる必要がある。

後述するReadDirectoryChangeWでは、変更されたファイル名も通知できるようになっているため、ReadDirectoryChangeWと比較してFindFirstChangeNotificationはスキャンの手間が多くなるように思われるが、実際のところ、ReadDirectoryChangeWは、いつでもファイル名まで通知できるとは限らず、スキャンと併用する必要がある。

むしろ、名前が特定されている場合はフルスキャンではなく部分的なスキャンだけで済む、という最適化ができるぐらいと考えたほうがよさそうである。


したがって、監視ファイル数が限定的で最適化までこだわらなければ、実際のところ、FindFirstChangeNotificationで充分なケースも多いと思われる。

欠点(欠陥?)

古い話ではあるが、FindFirstChangeNotification APIはネットワークドライブに対する監視が非常に脆く、あまり信頼性がないという印象がある。

  1. NT4ではネットワークが切断された場合、APIはエラーを検知せず、ネットワークが復旧しても監視を再開しなかった。
  2. Windows98LinuxのSMB共有に対して監視するとエラーにはならないが何も応答がないことがあった。
    (CentOS5のSMB共有で試したところ、ちゃんと通知は受け取れており、近年は、かなり良くなっているようであるが...。)
  3. 市販のNASでは、ファイル共有はできても、FindFirstChangeNotification APIを実行するとエラーを返して使えない機種もあるようである。(2010年の話)

...という経験から、ファイル監視系APIはローカルドライブに対して使うのが基本ではないか、と私は考えている。

ReadDirectoryChangesW APIを使ってファイルを監視する方法

WindowsNT系且つUNICODEビルドである場合にのみ使える方法として、ReadDirectoryChangeW関数がある。


このAPIの利点としては、変更の有無だけでなく、変更されたファイルが通知される、という点にある。


そこで、このAPIを使った場合にファイル監視のロジックが、どのようなものになるのか感触を確かめるべく、実験してみることとした。

実験コード
#include "stdafx.h"

// stdafx.hプリコンパイルヘッダでの定義は以下のとおり
// (XP SP2以降)
//#define _WIN32_WINNT 0x0502
//#include <stdio.h>
//#include <locale.h>
//#include <tchar.h>
//#include <vector>
//#include <conio.h>
//#include <Windows.h>


// エラーの表示
static void ShowError(LPCTSTR msg)
{
	DWORD errcode = GetLastError();
	_tprintf(_T("%s errorcode: %lx\r\n"), msg, errcode);
}

// キー入力のチェック
static inline bool CheckQuitKey()
{
	return _kbhit() && (_getch() == 'q');
}

// メインエントリ
int _tmain(int argc, _TCHAR* argv[])
{
	// コンソール出力を日本語可能に
	setlocale(LC_ALL, "");

	// オプション引数の値を保持する
	LPCTSTR pDir = _T("C:\\temp\\fwatch2v");
	size_t bufsiz = 0;
	int waittime = 0;
	bool hasError = false;

	// 引数の解析
	_TCHAR **pArg = &argv[1];
	while (*pArg) {
		if (_tcsicmp(_T("/b"), *pArg) == 0) {
			// バッファサイズ
			pArg++;
			if (*pArg) {
				bufsiz = _ttol(*pArg);
			}

		} else if (_tcsicmp(_T("/w"), *pArg) == 0) {
			// ウェイト時間
			pArg++;
			if (*pArg) {
				waittime = _ttoi(*pArg);
			}

		} else if (**pArg != '/') {
			// 監視先ディレクトリ
			pDir = *pArg;
			break;

		} else {
			_ftprintf(stderr, _T("不明な引数: %s\r\n"), *pArg);
			hasError = true;
		}

		pArg++;
	}
	if (hasError) {
		return 2;
	}

	if (bufsiz <= 0) {
		bufsiz = 1024 * 8;
	}
	if (waittime <= 0) {
		waittime = 0;
	}

	_tprintf(_T("監視先: %s\r\nバッファサイズ: %ld\r\nループ毎の待ち時間: %ld\r\n"),
		pDir, bufsiz, waittime);


	// 対象のディレクトリを監視用にオープンする.
	// 共有ディレクトリ使用可、対象フォルダを削除可
	// 非同期I/O使用
	HANDLE hDir = CreateFile(
		pDir, // 監視先
		FILE_LIST_DIRECTORY,
		FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
		NULL,
		OPEN_EXISTING,
		FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, // ReadDirectoryChangesW用
		NULL
		);
	if (hDir == INVALID_HANDLE_VALUE) {
		ShowError(_T("CreateFileでの失敗"));
		return 1;
	}

	// 監視条件 (FindFirstChangeNotificationと同じ)
	DWORD filter = 
		FILE_NOTIFY_CHANGE_FILE_NAME   |  // ファイル名の変更
		FILE_NOTIFY_CHANGE_DIR_NAME    |  // ディレクトリ名の変更
		FILE_NOTIFY_CHANGE_ATTRIBUTES  |  // 属性の変更
		FILE_NOTIFY_CHANGE_SIZE        |  // サイズの変更
		FILE_NOTIFY_CHANGE_LAST_WRITE;    // 最終書き込み日時の変更

	// 変更されたファイルのリストを記録するためのバッファ.
	// 最初のReadDirectoryChangesWの通知から次のReadDirectoryChangesWまでの
	// 間に変更されたファイルの情報を格納できるだけのサイズが必要.
	// バッファオーバーとしてもファイルに変更が発生したことは感知できるが、
	// なにが変更されたかは通知できない。
	std::vector<unsigned char> buf(bufsiz);
	void *pBuf = &buf[0];

	// 非同期I/Oの完了待機用, 手動リセットモード。
	// 変更通知のイベント発報とキャンセル完了のイベント発報の
	// 2つのイベントソースがあるためイベントの流れが予想できず
	// 自動リセットイベントにするのは危険。
	HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);

	// 変更通知を受け取りつづけるループ
	for (;;) {
		// Sleepを使い、イベントハンドリングが遅いケースをエミュレートする.
		// ループ2回目以降ならばSleep表示中にファイルを変更しても、
		// 変更が追跡されていることを確認できる.
		// またバッファサイズを超えるほどにたくさんのファイルを変更すると
		// バッファオーバーを確認できる。
		const int mx = waittime * 10;
		for (int idx = 0; idx < mx; idx++) {
			_tprintf(_T("sleep... %d/%d \r"), idx + 1, mx);
			Sleep(100);
		}
		_tprintf(_T("\r\nstart.\r\n"));

		// イベントの手動リセット
		ResetEvent(hEvent);

		// 非同期I/O
		// ループ中でのみ使用・待機するので、ここ(スタック)に置いても安全.
		OVERLAPPED olp = {0};
		olp.hEvent = hEvent;

		// 変更を監視する.
		// 初回呼び出し時にシステムが指定サイズでバッファを確保し、そこに変更を記録する.
		// 完了通知後もシステムは変更を追跡しており、後続のReadDirectoryChangeWの
		// 呼び出しで、前回通知後からの変更をまとめて受け取ることができる.
		// バッファがあふれた場合はサイズ0で応答が返される.
		if (!ReadDirectoryChangesW(
			hDir,   // 対象ディレクトリ
			pBuf,   // 通知を格納するバッファ
			bufsiz, // バッファサイズ
			TRUE,   // サブディレクトリを対象にするか?
			filter, // 変更通知を受け取るフィルタ
			NULL,   // (結果サイズ, 非同期なので未使用)
			&olp,   // 非同期I/Oバッファ
			NULL    // (完了ルーチン, 未使用)
			)) {
			// 開始できなかった場合のエラー
			ShowError(_T("ReadDirectoryChangesWでの失敗"));
			break;
		}

		// 完了待機ループ (qキーで途中終了)
		bool quit;
		while (!(quit = CheckQuitKey())) {
			// 変更通知まち
			DWORD waitResult = WaitForSingleObject(hEvent, 500); // 0.5秒待ち
			if (waitResult != WAIT_TIMEOUT) {
				// 変更通知があった場合 (イベントがシグナル状態になった場合)
				break;
			}
			// 待ち受け表示
			_tprintf(_T("."));
		}
		_tprintf(_T("\r\n"));

		if (quit) {
			// 途中終了するなら非同期I/Oも中止し、
			// Overlapped構造体をシステムが使わなくなるまで待機する必要がある.
			CancelIo(hDir);
			WaitForSingleObject(hEvent, INFINITE);
			break;
		}

		// 非同期I/Oの結果を取得する.
		DWORD retsize = 0;
		if (!GetOverlappedResult(hDir, &olp, &retsize, FALSE)) {
			// 結果取得に失敗した場合
			ShowError(_T("GetOverlappedResultでの失敗"));
			break;
		}

		// 変更通知をコンソールにダンプする.
		_tprintf(_T("returned size=%ld\r\n"), retsize);

		if (retsize == 0) {
			// 返却サイズ、0ならばバッファオーバーを示す
			_tprintf(_T("buffer overflow!!\r\n"));

		} else {
			// 最初のエントリに位置付ける
			FILE_NOTIFY_INFORMATION *pData =
				reinterpret_cast<FILE_NOTIFY_INFORMATION*>(pBuf);
			
			// エントリの末尾まで繰り返す
			for (;;) {
				// アクションタイプを可読文字に変換
				TCHAR *pActionMsg = _T("UNKNOWN");
				switch (pData->Action) {
				case FILE_ACTION_ADDED:
					pActionMsg = _T("Added");
					break;
				case FILE_ACTION_REMOVED:
					pActionMsg = _T("Removed");
					break;
				case FILE_ACTION_MODIFIED:
					pActionMsg = _T("Modified");
					break;
				case FILE_ACTION_RENAMED_OLD_NAME:
					pActionMsg = _T("Rename Old");
					break;
				case FILE_ACTION_RENAMED_NEW_NAME:
					pActionMsg = _T("Rename New");
					break;
				}

				// ファイル名はヌル終端されていないので
				// 長さから終端をつけておく.
				DWORD lenBytes = pData->FileNameLength; // 文字数ではなく, バイト数
				std::vector<WCHAR> fileName(lenBytes / sizeof(WCHAR) + 1); // ヌル終端用に+1
				memcpy(&fileName[0], pData->FileName, lenBytes);

				// アクションと対象ファイルを表示.
				// (ファイル名は指定ディレクトリからの相対パスで通知される.)
				_tprintf(_T("[%s]<%s>\r\n"), pActionMsg, &fileName[0]);

				if (pData->NextEntryOffset == 0) {
					// 次のエントリは無し
					break;
				}
				// 次のエントリの位置まで移動する. (現在アドレスからの相対バイト数)
				pData = reinterpret_cast<FILE_NOTIFY_INFORMATION*>(
					reinterpret_cast<unsigned char*>(pData) + pData->NextEntryOffset);
			}
		}
	}

	// ハンドルの解放
	CloseHandle(hEvent);
	CloseHandle(hDir);

	return 0;
}

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


このコンソールプログラムは、引数として

  • /b nnn バッファサイズ (省略時は8k)
  • /w nnn 待機時間 (省略時は0) (0を指定すると待機なし)
  • 監視先フォルダ

を指定することができ、監視先フォルダでファイルの作成・変更・削除すると、作成・変更・削除したファイルの相対パスがコンソールに出てくる。


引数に「/w 10」とか指定すると、通知を受けてから監視待機を再開するまで待ち時間を長くとれるので、これでバッファがあふれた場合の動きを見ることができる。

Windows7(x64)での実行例
C:\temp\working\fwatch2v\Debug>fwatch2v.exe /w 5 C:\temp
監視先: C:\temp
バッファサイズ: 8192
ループ毎の待ち時間: 5
sleep... 50/50
start.
.....................
returned size=44
[Added]<working\新しいフォルダー>
sleep... 50/50
start.

returned size=26
[Modified]<working>
sleep... 50/50
start.

returned size=114
[Rename Old]<working\新しいフォルダー>
[Rename New]<working\TESTDIR>
[Modified]<working>
sleep... 50/50
start.

returned size=122
[Added]<working\TESTDIR\新しいテキスト ドキュメント.txt>
[Modified]<working\TESTDIR>
sleep... 50/50
start.

returned size=266
[Rename Old]<working\TESTDIR\新しいテキスト ドキュメント.txt>
[Rename New]<working\TESTDIR\TEST_FILE.txt>
[Modified]<working\TESTDIR>
[Modified]<working\TESTDIR\TEST_FILE.txt>
sleep... 50/50
start.

returned size=70
[Removed]<working\TESTDIR>
[Modified]<working>
sleep... 50/50
start.
........
C:\temp\working\fwatch2v\Debug>
Windows Server 2008 R2の共有フォルダをWindowsXPから監視した場合

ネットワーク上のWindows2008R2サーバの共有フォルダを監視対象にしても、一応、動いているように見えた。(こまかな挙動や、ネットワークエラー発生時にどうなるのかは、まだ確認していない。)

Windows7上でFAT仮想イメージをTrueCryptでマウントした場合

ReadDirectoryChangesWはNTFSジャーナリングの仕組みで動いているという記事をどこかで見た気がするが、試しにマウントしてみたFATドライブでも監視できた。(各種リムーバブルディスクで大丈夫であるか、ExFATとかFAT32で大丈夫かは試していない。)

ジャーナリング云々は、別の話だったかもしれない。

Windows7上からMacのSMB共有を監視した場合

少し試した限りでは、MacのSMBファイル共有では、変更通知を受け取った場合でも、いつでもデータサイズ0(バッファオーバーと同じ)になっていて、なにが変更されたかは通知されない。また、サブフォルダ内も監視できない様子。

C:\temp\working\fwatch2v\Debug>fwatch2v.exe /w 0 \\SERAPHY-IMAC5\seraphy
監視先: \\SERAPHY-IMAC5\seraphy
バッファサイズ: 8192
ループ毎の待ち時間: 0

start.
...................................
returned size=0
buffer overflow!!

start.
.....
returned size=0
buffer overflow!!

start.
................
C:\temp\working\fwatch2v\Debug>

結論

機能からみた特徴
  • FindFirstChangeNotification関数とは異なり、ファイルを変更すると、ただちに対象ファイルが特定される。
  • ReadDirectoryChangesWを最初に呼び出したあと、通知を受けてから次のReadDirectoryChangesWを呼び出すまでの間にファイルが変更された場合でも、きちんとファイルの変更を追跡してくれる。
  • ただし、バッファオーバーした場合や、監視先の相手側が個別に通知できない場合は、変更の有無だけが通知され、どのファイルが変更されたかはわからない。ファイルがいつでも受け取れるとは想定することはできない。
プログラム的な観点
  • ディレクトリに対するハンドルを特別な方法で取得する必要がある。
  • バッファに対する操作が煩雑。バイトと文字数の計算とか、ファイル名がヌル終端でないとか。
  • 非同期I/Oを使わないと監視によって処理がブロッキングされるため、非同期I/Oの使用はほぼ必須といえる。
使い勝手の評価

バッファオーバーした場合はファイルが特定できず、また、いつでもファイルが特定できるとは限らないため、基本的にはFindFirstChangeNotificationと同じ使い方、つまりスキャンと併用するのが基本的な使い方だと考えられる。
(もし、ファイルが特定されている場合は、フルスキャンを省略して部分的なスキャンだけで済む、という最適化ができる。)


ちょっと注意しなければならないかな、と思う点は、フォルダが削除された場合、当然、フォルダ削除は通知されるが、フォルダの中にいた子も削除されたことは通知されない、という点である。

フォルダが消された場合に、それが保持していた子供も消えたことを認識させるには、やはり事前に一度はスキャンしていなければならない、ということになるだろう。


変更があった時点で通知され、いつ変更が完了したかはわからない、というのはFindFirstChangeNotificationで説明したものと同じであり、検出方法も、同様な方法をとる必要があるものと思われる。


また、MacのSMB共有でファイル通知がうまくされないところをみても、やはりファイル監視系APIではネットワークでの監視、とくに非Windowsマシンへの監視は当てにはできなさそう、という点はFindFirstChangeNotificationのケースと同じであろうことが予想される。


結論として、ReadDirectoryChangeW関数を使ったとしてもFindFirstChangeNotificationを使った場合よりロジックが劇的に簡略化されることはなく、ほぼ同等な処理が必要であり、若干の最適化のメリットがある程度と考えられる。
(ただし監視ファイル数が多い場合は、この最適化が大きなパフォーマンス上の利点を生む可能性はある。)

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等、ディレクトリ上でファイル等が追加されると親ディレクトリのタイムスタンプも変更されるため、直接の子のファイルの追加・削除のタイミングで親ディレクトリの変更が通知されることはある。