今回のお題は「ディレクトリの更新監視」です。
こんにちは! h.kitachi です。
お題であるディレクトリの更新監視ですが、システムの正常性管理には不可欠で、弊社で扱う案件の中でも要件として挙がってくることがあります。
業務では実績のある既存のアプリケーションを組み合わせて対応するのですが、カスタマイズ性の面から独自開発のアプリケーションがあれば良かったのに……ということも稀にあります。
本ブログ内でのアプリケーション開発はさすがに無理ですが、エッセンスの部分だけでも実験的にプログラミング演習を行ってみようと思いました。
目次
言語は Rust を選択
監視機能ですから、メモリ上に常駐して継続的に動作することが求められます。
プログラミング言語を選定するにあたって、以下の点を検討し、 Rust に決定しました。
- 安定して長期間継続的に動作できる
Rust 最大の売りである(と個人的に思っている)安全性の高いメモリ管理機構により、メモリリークやアクセス違反の心配をせずに、長期間継続して動作するアプリケーションを作ることができます。
- CPU 負荷が少ない
Rust はネイティブコードへコンパイルされるため、その実行速度は C 言語などと同等です。
また、GC(ガベージコレクション)も必要ないため、不要メモリのチェックや回収にまとまった CPU 時間を取られることもありません。
- 運用環境への導入が容易
Rust は Linux や Windows などの主要なプラットフォームの実行ファイル生成に対応しています。
また、単一の実行ファイルを作ることができるため、VM やインタプリタなどの大きな実行環境を別途インストールする必要がありません。
- メモリ使用量が少ない
前述のとおり、VM やインタプリタを必要としませんので、メモリフットプリントも最小限です。
notify の概要
今回は notify というファイルシステムの変更を通知するライブラリを利用します。
https://crates.io/crates/notify
クロスプラットフォーム対応で、OS 組み込みの通知機能がデフォルトのバックエンドとして使われるようになっており、効率的に動作します。
Windows の場合は Windows API の ReadDirectoryChangesW が使われます。
高レベルのファイルアクセスによる独自のポーリング処理(つまり、自力でファイルを見張る処理)で更新を検知する PollWatcher というバックエンドも用意されています。
PollWatcher は低レベルな API を使用するデフォルトのバックエンドよりもパフォーマンスが落ちますが、デフォルトのバックエンドでは問題が生じる場合の代替先として有用です。
notify のドキュメントでは、PollWatcher を代替使用すべき例として、WSL2 から Windows 側ファイルを監視する場合や、Linux の /proc, /sys ディレクトリを監視する場合が挙げられています。
演習1:notify を使う
さっそく、notify を使ってみます。
Hello World が実行できる程度に Rust の開発環境がセットアップ済みである前提です。
まず、Cargo.toml
の dependencies
へ notify
を追加します。
[package]
name = "rust_win"
version = "0.1.0"
edition = "2024"
[dependencies]
notify = "8.0.0"
notify のドキュメントに記載されているサンプルコードが短くて分かりやすいので、これを使わせてもらうことにします。
use notify::{Event, RecursiveMode, Result, Watcher, recommended_watcher};
use std::{path::Path, sync::mpsc::channel};
fn main() -> Result<()> {
let (tx, rx) = channel::<Result<Event>>();
let mut watcher = recommended_watcher(tx)?;
watcher.watch(Path::new("."), RecursiveMode::Recursive)?;
for res in rx {
match res {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
}
Ok(())
}
ざっと処理の流れを説明します。
まず、イベントを受け取るための非同期チャネル(送信の tx
と受信の rx
)を作成します。
ディレクトリ監視を行う watcher
を推奨設定(API を使う設定)で生成し、イベントの送り先として tx
を渡します。
監視対象パスを指定(watcher.watch
)すると、監視が始まります。
後は rx
を見張って、受信したイベント情報を標準出力へ書き出します。
最後の受信処理は無限ループになるので、実行したプログラムは Ctrl + C で強制終了させる必要があります。
本稿の演習は Windows 環境で行いました。
サンプルコードにプラットフォーム依存箇所はないため、他の OS でも動作すると思いますが、更新検知のイベント情報の内容は少し異なることがあるかもしれません。
サンプルプログラムをビルドして実行すると、カレントディレクトリの監視が始まります。
監視がちゃんと行われるか、以下のファイル操作を手作業で順番に実施してみました。
test1.txt
ファイルを作成するtest1.txt
ファイルへ適当なテキストを入力してセーブするtest1.txt
をtest2.txt
へリネームするtest2.txt
を削除する
出力されたイベント情報は以下の通りです。
(1. test1.txt ファイルを作成する)
event: Event { kind: Create(Any), paths: ["rest_win\\.\\test1.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
(2. test1.txt ファイルへ適当なテキストを入力してセーブする)
event: Event { kind: Modify(Any), paths: ["rest_win\\.\\test1.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
event: Event { kind: Modify(Any), paths: ["rest_win\\.\\test1.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
(3. test1.txt を test2.txt へリネームする)
event: Event { kind: Modify(Name(From)), paths: ["rest_win\\.\\test1.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
event: Event { kind: Modify(Name(To)), paths: ["rest_win\\.\\test2.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
(4. test2.txt を削除する)
event: Event { kind: Remove(Any), paths: ["rest_win\\.\\test2.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
いずれのイベント情報もファイル操作すると即時に出力されました。
いくつか興味深い点があります。
- イベント情報のうち
Kind
,paths
以外は全てNone
となっており、残念ながら情報量はあまり多くありません。
Modify(Any)
イベントが 2 回続けて出力されています。
これは推測になりますが、ファイルのサイズ更新と最終日時更新をそれぞれ検知しているものと思われます。
Windows API 側の通知情報FILE_NOTIFY_INFORMATION
に「ファイルのどの属性が更新されたのか」という情報が含まれないため、どうしようもない部分です。
- リネームが 2 つのイベントに分割されています。
この例だと 1 つのファイルしか操作していないので分かりやすいですが、複数のファイルが同時にリネームされた場合は、リネーム前後の対応が取れない可能性があります。
これもFILE_NOTIFY_INFORMATION
にリネーム前後の対応情報が含まれておらず、どうしようもない部分です。
演習2:notify-debouncer-mini を使う
notify は検出速度が早い反面、通知内容がプリミティブで頻度が高くなってしまいます。
その対応策として、notify には debouncer という通知頻度を抑制する(debounce する)ラッパーが 2 種類用意されています。
notify-debouncer-mini はそのうちのひとつ、簡易的な debouncer です。
出力されるイベント情報を「同じ時間枠(timeframe)、同じファイルにつき 1 イベント」となるようにフィルタリングします。
さっそく使ってみましょう。
Cargo.toml
の dependencies
を notify-debouncer-mini
へ差し替えます。
notify は notify-debouncer-mini の依存関係に含まれるため、notify の特定バージョンを使う必要性がなければ記載不要です。
[dependencies]
notify-debouncer-mini = "0.6.0"
サンプルコードを以下のように書き換えました。
watcher を生成する代わりに debouncer
を生成します。
今回は時間枠を 1 秒に設定しました。1 秒以内の連続するイベントはひとまとめになります。
debouncer
は watcher を内包しているので明示的に生成する必要はありません。
監視パスの登録やチャネルを介してのイベント送受信は全く同じです。
use notify_debouncer_mini::{
new_debouncer,
notify::{RecursiveMode, Result},
};
use std::{path::Path, sync::mpsc::channel, time::Duration};
fn main() -> Result<()> {
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_secs(1), tx)?;
debouncer
.watcher()
.watch(Path::new("."), RecursiveMode::Recursive)?;
for res in rx {
match res {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
}
Ok(())
}
演習1と同じ手順を実施した結果が以下です。
(1. test1.txt ファイルを作成する)
event: [DebouncedEvent { path: "rust_win\\.\\test1.txt", kind: Any }]
(2. test1.txt ファイルへ適当なテキストを入力してセーブする)
event: [DebouncedEvent { path: "rust_win\\.\\test1.txt", kind: Any }]
(3. test1.txt を test2.txt へリネームする)
event: [DebouncedEvent { path: "rust_win\\.\\test2.txt", kind: Any }, DebouncedEvent { path: "rust_win\\.\\test1.txt", kind: Any }]
(4. test2.txt を削除する)
event: [DebouncedEvent { path: "rust_win\\.\\test2.txt", kind: Any }]
素の notify とは異なり、ファイル操作を行ってから指定した時間枠(1 秒)ほどおいて、イベントが出力されました。
もちろん当然の動作ですが、遅延が生じるというデメリットを実感した瞬間でした。
また、最初にこの実行結果を見たときは意外でした。
ファイル操作の詳細が失われて、「どのファイルが更新されたか」しか分かりません。
しかし、これは notify-debouncer-mini の仕様通りなのです。
ソースコードを見てみました。
イベントの種類がそもそも次の 2 つしか定義されていません。
pub enum DebouncedEventKind {
/// No precise events
Any,
/// Event but debounce timed out (for example continuous writes)
AnyContinuous,
}
notify-debouncer-mini のイベント情報を保持する構造体 DebouncedEvent
のコメントにも次のように書いてあります。
/// A debounced event.
///
/// Does not emit any specific event type on purpose, only distinguishes between an any event and a continuous any event.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DebouncedEvent {
/// Event path
pub path: PathBuf,
/// Event kind
pub kind: DebouncedEventKind,
}
Does not emit any specific event type on purpose,
意図的に特定のイベント種類を出力していません。
only distinguishes between an any event and a continuous any event.
何らかのイベントと連続する何らかのイベントだけを区別します。
ファイルに変化があったことだけを検知して、何があったかの詳細は実際にそのファイルを参照して調べる、というような用途であれば問題はなさそうです。
例えば、ログファイルの追記分を逐次監視するようなツールなどですね。
演習3:notify-debouncer-full を使う
notify-debouncer-full は特にリネームに関して特別な実装を持った debouncer です。
素の notify では実現できなかったリネーム前後のイベント結合などを実現しています。
Cargo.toml
の dependencies
を notify-debouncer-full
へ変更します。
notify は notify-debouncer-full の依存関係に含まれるため、notify の特定バージョンを使う必要性がなければ記載不要です。
[dependencies]
notify-debouncer-full = "0.5.0"
サンプルは以下のようになります。
use notify_debouncer_full::{
new_debouncer,
notify::{RecursiveMode, Result},
};
use std::{path::Path, sync::mpsc::channel, time::Duration};
fn main() -> Result<()> {
let (tx, rx) = channel();
let mut debouncer = new_debouncer(Duration::from_secs(1), None, tx)?;
debouncer.watch(Path::new("."), RecursiveMode::Recursive)?;
for res in rx {
match res {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
}
Ok(())
}
use
の対象と、監視パスの指定方法が少し違うこと以外は、 notify-debouncer-mini と同じです。
演習1と同じ手順を実施した結果が以下です。
(1. test1.txt ファイルを作成する)
event: [DebouncedEvent { event: Event { kind: Create(Any), paths: ["rust_win\\.\\test1.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }, time: Instant { t: 488779.0530069s } }]
(2. test1.txt ファイルへ適当なテキストを入力してセーブする)
event: [DebouncedEvent { event: Event { kind: Modify(Any), paths: ["rust_win\\.\\test1.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }, time: Instant { t: 488781.9186081s } }]
(3. test1.txt を test2.txt へリネームする)
event: [DebouncedEvent { event: Event { kind: Modify(Name(Both)), paths: ["rust_win\\.\\test1.txt", "rust_win\\.\\test2.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }, time: Instant { t: 488788.0625528s } }]
(4. test2.txt を削除する)
event: [DebouncedEvent { event: Event { kind: Remove(Any), paths: ["rust_win\\.\\test2.txt"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }, time: Instant { t: 488792.210374s } }]
notify-debouncer-mini と同様に、ファイル操作とイベント出力には指定した時間枠(1 秒)の時間差があります。
操作 1 つにつき 1 イベントが綺麗に対応しています。
また、謳い文句通り、リネーム操作のイベント情報が結合されていて、どのファイルがどのような名前にリネームされたのかを知ることができます。
前述の通り、API レベルでリネーム前後を紐づける情報がないはずなのに、どうやっているのだろうと思い、少しソースコードを覗いてみました。
どうやら、ReadDirectoryChangesW
API の呼び出しとは別に、監視対象のファイル ID を取得してキャッシングしておいて、リネーム前後の紐づけを行っているようです。
ファイル ID を保持する分だけ余分なメモリを必要とする点に注意が必要ですね。
ReadDirectoryChangesW
ではなく、拡張版である ReadDirectoryChangesExW
を使用して、FILE_NOTIFY_INFORMATION
ではなく、拡張版の FILE_NOTIFY_EXTENDED_INFORMATION
を受け取るようにすれば、API の戻り値からファイル ID を取得できそうです。
notify-debouncer-full のファイル ID を別途取得・保持するというアプローチがパフォーマンスの問題となるようであれば、最終手段ではありますが、notify へ手を入れて改良する手は残されているかもしれません。
まとめ
独特の記法が多く、難しい印象の Rust ですが、今回作成したサンプルプログラムくらいなら問題ありませんでした。
次回があれば、また Rust を使った記事を書いてみたいと思います!