Logrep
ちょっとしたコマンドラインツールを書きました。
https://github.com/reismannnr2/logrep
得られた知見
条件次第で変化するクロージャを使ったループ処理を書くとき、以下の構成にするとループ内のクロージャ呼び出しを静的ディスパッチできる。
- 「クロージャを使ったループ処理」をする構造体を作る。
- ループ処理のエントリポイントをトレイト化する。
- 条件分岐で1の構造体を返す関数を作成し、戻り値を2のトレイトオブジェクトにする。
経緯
仕事で数十万行のログファイルを漁ることがよくあるのですが、普通にgrepすると以下のように複数行のログのヒット行だけが出力されて大変うれしくない。
[YYYY/MM/DD HH:MM:SS.ZZZ GMT] ERROR SUMMARY LINE
BLA BLA BLA
FOO BAR BAZ
HOGE FUGA PIYO
[YYYY/MM/DD HH:MM:SS.ZZZ GMT] DEBUG SUMMARY LINE
DER DES DEM DEN
DIE DER DER DIE
DAS DES DEM DAS
grep FOO sample.log
FOO BAR BAZ
数行のセットで1単位なので FOO
で検索したら以下のように出したい。
[YYYY/MM/DD HH:MM:SS.ZZZ GMT] ERROR SUMMARY LINE
BLA BLA BLA
FOO BAR BAZ
HOGE FUGA PIYO
要件的なもの
もともとの動機が数十MB程度のログをフィルタリングすることだったので、以下のようなイメージで作り始めた。
- せいぜい数十MB程度までなので、メモリは気にしなくていい
- 数十MBのテキストを扱いたいので、そこそこ高速化はしておきたい
- デリミタ行の書式は環境設定とオプションで変更できるようにする
- 正規表現検索と完全一致検索
- 単一ファイル以外に、標準入力にも対応
- パイプで繰り返しフィルタかけられると便利だなーと思ったので
- 反対に、1行でもマッチ行が存在するブロックを除外するフラグ
苦労した点とか
クロージャの型問題。
Rustのクロージャは静的ディスパッチをするのでとても高速というのは知識として知っていました。
しかしその反面、引数と戻り値が一緒でも、条件によって違う複数のクロージャを同じ変数に入れたりできない。
// 各クロージャの型が違うのでコンパイルエラー
fn get_closure(pattern: &str, use_regex: bool, case_insensitive: bool) -> impl Fn(&str) -> bool {
match (use_regex, case_insensitive) {
(true, true) => {
// エラー処理は今回の本質ではないので省略
let pattern = Regex::new(&format!("(?i){}", pattern)).unwrap();
move |line: &str| pattern.is_match(line)
},
(true, false) => {
let pattern = Regex::new(pattern).unwrap();
move |line: &str| pattern.is_match(line)
},
(false, true) => {
let pattern = pattern.to_lowercase();
move |line: &str| line.to_lowercase().contains(&pattern)
},
(false, false) => {
|line: &str| line.contains(pattern)
}
}
}
クロージャの中で分岐したり、 Box<dyn Fn(&str) -> bool>
とかしたりすると、一回のコマンドでは条件は毎回同じなのに、数十万回のループで毎回動的ディスパッチや判定分岐が入ってよろしくない。
一方、 enum とかで分岐するとこれはこれでコードが分岐だらけになって汚くてよろしくない。
解決策:
pub trait Searcher {
fn search_from_str<'a>(&self, content: &'a str) -> Vec<&'a str>;
}
pub struct<F> BlockSearcher<F>
where F: Fn(&str) -> bool
{
matcher: F
}
impl <F> Searcher for BlockSearcher<F>
where F: Fn(&str) -> bool
{
fn search_from_str<'a>(&self, content: &'a str) -> Vec<&'a str> {
// この中でクロージャ `self.matcher` を使ったループ処理
}
}
fn get_searcher(pattern: &str, use_regex: bool, case_insensitive: bool) -> Box<dyn Searcher> {
match (use_regex, case_insensitive) {
(true, true) => {
let pattern = Regex::new(&format!("(?i){}", pattern)).unwrap();
Box::new(BlockSearcher { matcher: move |line: &str| pattern.is_match(line) })
},
(true, false) => {
let pattern = Regex::new(pattern).unwrap();
Box::new(BlockSearcher { move |line: &str| pattern.is_match(line) })
},
(false, true) => {
let pattern = pattern.to_lowercase();
Box::new(BlockSearcher { move |line: &str| line.to_lowercase().contains(&pattern) })
},
(false, false) => {
Box::new(BlockSearcher { |line: &str| line.contains(pattern) })
}
}
}
let searcher = get_searcher(pattern, true, false);
let result = searcher.search_from_str(text);
こうしておけば汚い分岐は get_searcher
に閉じ込めたまま、他の部分は抽象化したコードが書ける。
動的ディスパッチが起こるのは searcher.search_from_str(text)
を呼ぶ箇所の一回だけで、ループ内では静的ディスパッチしか行われない、不要な分岐をしないため高速。のはず。
クロージャの所有者をトレイトオブジェクトに閉じ込めて、トレイトメソッドの内部でクロージャを使ったループをすることで、ループ内では静的ディスパッチを維持したまま、型の違うクロージャを1つの変数に入れて取り扱うことができました。
結論
Rustは楽しい。