Haskell でファイルシステムの変更を検出する

この記事は Haskell Advent Calendar 2013 の 15 日目の記事です。

Haskellファイルシステムの変更を検出する方法を調査してみました。

いきなり Java で恐縮ですが、イメージとしては以下の様な感じです。

import java.nio.file.*;
import static java.nio.file.StandardWatchEventKinds.*;

public class Watcher {
    public static void main(String[] args) throws Exception {
        Path dir = Paths.get(".");
        WatchService watcher = FileSystems.getDefault().newWatchService();
        dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);

        for (WatchKey key = watcher.take(); ; key.reset()) {
            for (WatchEvent<?> event : key.pollEvents()) {
                WatchEvent.Kind<?> kind = event.kind();
                if (kind == OVERFLOW) {
                    continue;
                }
                System.out.format("%s: %s\n", kind, event.context());
            }
        }
    }
}

今回は Haskell 主体の記事なので、上記コードの説明は割愛します。以下のようにコンパイル、実行します。

C:\works>javac Watcher.java
C:\works>java Watcher
ENTRY_DELETE: text.txt
ENTRY_CREATE: text.txt
ENTRY_MODIFY: text.txt

出力されているログは、text.txt というファイルを秀丸エディタで上書き保存した際のものです。上書き保存は内部的には削除、作成、変更という手順で行われているんですね。興味深いです。


さて、HaskellJava の WatchService 相当が行えるモジュールとして、hackage に fsnotify が公開されています。

これを使用できるように cabal でインストールするには、以下のように行います。

C:\works>cabal install fsnotify

このモジュールを使って実装したファイルシステム監視関数は以下のとおりです。

module Watcher (watch) where

import Control.Concurrent
import Filesystem (getWorkingDirectory)
import System.FSNotify
import System.Exit
import System.IO.Error

watch :: (Event -> IO ()) -> IO ()
watch f = do
  dir <- getWorkingDirectory
  withManager $ \manager -> do
    watchDir manager dir (const True) $ \event -> f event
    waitBreak
  where
    waitBreak = do
      _ <- catchIOError getLine (\e -> if isEOFError e then exitSuccess else exitFailure)
      waitBreak

ここで使用している watchDir という関数が、fsnotify の提供するファイルシステム監視関数です。以下の様な定義になっています。

data Event =
    Added    FilePath UTCTime
  | Modified FilePath UTCTime
  | Removed  FilePath UTCTime
  deriving (Eq, Show)

type ActionPredicate = Event -> Bool

type Action = Event -> IO ()

watchDir :: WatchManager -> FilePath -> ActionPredicate -> Action -> IO ()

Event は追加、変更、削除があり、ファイルパスとタイムスタンプを持っています。

ActionPredicate は、Event を受け取り Bool を返すフィルタ関数の定義です。今回の実装では (const True) で常に True を返すようにしています。

Action は Event を受け取り IO () を返すユーザ定義コールバック関数です。今回の実装ではこのコールバック関数を外部から渡せるようにしています。

waitBreak という関数は、Ctrl+C で割り込みをした際にプロセスを終了させるために存在します。これは必ずしも必要ではなく、Java の挙動に合わせた感じです。

この関数を呼び出すサンプルコードは以下のとおりです。

import Watcher

main = watch print

コールバック関数として print を渡し、Event がそのまま表示されるようにしています。

これを以下のようにコンパイル、実行します。

C:\works>ghc WatcherTest.hs
C:\works>WatcherTest.exe
Removed (FilePath "text.txt") 2013-12-15 12:04:55.6163553 UTC
Added (FilePath "text.txt") 2013-12-15 12:04:55.6223556 UTC
Modified (FilePath "text.txt") 2013-12-15 12:04:55.6273559 UTC

同様に秀丸で text.txt を上書き更新した際のログです。大体同じように動作することが確認できました。

使い方も結構簡単だし、fsnotify はマルチプラットフォームだし、watchDir の代わりに watchTree を使用すればサブディレクトリを再帰的に検知するし、めでたし、めでたし。







・・・、では終わらないんですね。

fsnotify を使用して気がついた点をいくつかピックアップします。

ファイルシステム変更全般

Java の場合、

監視対象のディレクトリ内のファイルが変更されたことを知らせるイベントが報告された場合、そのファイルを変更したプログラムが完了しているという保証はありません。

WatchService (Java Platform SE 7)

という記載が Javadoc にあります。これは、Java が動作するプラットフォームやファイルシステム、ポーティングレイヤーの実装に依存し、検出が同期だったり非同期だったりするためです。通知はあれど未完、というのは扱いにくいですね。

fsnotify にはこうした記載はないのですが、おそらく同様の考え方が必要になるはずです。

ディレクトリ生成が検知できない

今回は fsnotify 0.0.11 で動作確認しましたが、Windows 上でディレクトリ生成を検知することができないようです。何度試しても検知できませんでした。

イベントをかなりの確率で取りこぼす

原因はよくわかりません。取りこぼし率は、体感で 3 割以上はありそうです。特に Added が通知されないことが多いように感じます。

fsnotify の実装を調べてみたところ、内部では同一作者の実装する System.Win32.Notify というモジュールが使用されており、そこでは Win32 API の ReadDirectoryChangesW が使用されていました。丁寧にデバッグすれば原因が System.Win32.Notify モジュールにあるのか、fsnotify にあるのか、それとも今回のサンプルコードにあるのかはわかるかも知れませんが*1、今回はそこまでは追いかけていません。

本気で使うときに本気でデバッグしてみようと思います。



それでは皆さん、ハッピークリスマス!

*1:原因が GHC や大穴で Win32 API にあるようなら、お手上げ