はじめに
GMOコネクトの星です。
大規模なJavaプロジェクトの横断調査で、エディタ標準のGREP機能が重くて作業が止まる、という状況に陥っていました。「ちょっと文字列の出現箇所を確認したい」だけなのに数十秒待たされ、しかも条件を変えてやり直す、というのを1日に何度もやると、それだけで時間が溶けます。
そこで「AIに業務特化の使い捨てツールを作らせる」というアプローチで、自分専用のGREPツールを自作してみました。やってみると思ったよりたくさんの落とし穴があり、特に chardet のエンコーディング判定には完全にハマりました。ツール本体よりも、自作の過程で踏んだ地雷とAIをどう使って抜け出したかをまとめます。
作ったもの:fastgrep
最終形のツールはおおむね以下のような構成です。
- 1ファイル、外部依存は
chardetのみ(無くてもフォールバック動作) -
concurrent.futures.ProcessPoolExecutorでCPUコア数ぶんの並列検索 - 出力フォーマットはサクラエディタ風(
相対パス(行番号): マッチ行) - 結果は標準出力 + ファイルに必ず両出力
-
.git/node_modules/__pycache__などは自動スキップ
設計判断として「50ファイル未満ではあえて並列化しない」ようにしています。ファイル数が少ないとプロセス起動コストの方が高くつくため、閾値で自動切替する形にしました。
if workers > 1 and len(files) > 50:
with ProcessPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(search_file, f, pattern): f for f in files}
for future in as_completed(futures):
all_results.extend(future.result())
else:
for f in files:
all_results.extend(search_file(f, pattern))
map ではなく submit + as_completed にしているのは、submit順に縛られず、遅いファイルが先頭にいてもブロックされないからです。将来「ヒットが出た瞬間に画面に流す」拡張も入れやすくなります。
つまずき①:Windowsパスの \ が消える事件
完成して最初に動かしたとき、妙な副作用に遭遇しました。
python fastgrep.py "検索文字列" C:\Users\<アカウント名>\git
これを叩いた結果、カレントディレクトリの下に Users<アカウント名>git という意味不明なフォルダが出来上がるという事故が起きました。
原因はクォート無しでバックスラッシュ込みパスを渡したことです。Git Bash経由で実行していたため、bashが \ をエスケープ文字として解釈し、\U、\<、\g が単なる U、<、g に潰されてしまい、パスが「相対パスっぽい文字列」に化けていました。検索対象が存在しないので新規作成扱いで、変な名前のフォルダが残った、という流れです(cmd.exe から直接実行している場合はこの現象は起きません)。
教訓は単純で、Windowsパスは必ずダブルクォートで囲む、これだけ。
# NG
python fastgrep.py "検索文字列" C:\Users\foo\git
# OK
python fastgrep.py "検索文字列" "C:\Users\foo\git"
ただこの教訓、当然自分は実行時にすぐ忘れます。なのでAIに使い方ドキュメント(fastgrep_usage.txt)も同時に作ってもらい、「★ ディレクトリパスは必ずダブルクォーテーション(") で囲んでください ★」とでかでかと書かせました。AIに自作ツールを作らせるときは、利用ガイドの整備までセットでやらせるのが体験として一段良くなります。
つまずき②:既存エディタのGREP結果と件数がズレる
検証として、信頼している既存環境のGREP結果と突き合わせたところ、自作ツール側で20件強のヒットが欠落していました。欠落していたのはほぼ .properties ファイルです。
ありがちなのは文字コード判定の差なので、AIに相談したところ「chardet の誤判定の可能性が高い」という見立てが返ってきました。.properties のように ASCII文字が大半で日本語が極少量のファイルでは、chardet が「これはASCIIだ」と高い自信で誤判定してしまい、日本語部分のUTF-8マルチバイトがデコード時に化けてマッチしなくなる、というわけです。
そこで v2 として、エンコーディング判定の優先順位を組み替えました。
def detect_encoding(raw: bytes) -> str:
# BOM最優先
if raw[:3] == b"\xef\xbb\xbf":
return "utf-8-sig"
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
return "utf-16"
# ★ chardetより先にUTF-8厳密デコードを試す
try:
raw.decode("utf-8", errors="strict")
return "utf-8"
except UnicodeDecodeError:
pass
# chardetはあくまでフォールバック
if HAS_CHARDET:
result = chardet.detect(raw[:4096])
if result and result["encoding"]:
enc = result["encoding"].lower()
if enc in ("shift_jis", "shift-jis", "sjis"):
return "cp932"
return enc
for enc in ("cp932", "euc-jp", "iso-2022-jp", "latin-1"):
try:
raw[:4096].decode(enc)
return enc
except (UnicodeDecodeError, LookupError):
continue
return "latin-1"
やっていることは chardet を「最初に呼ぶライブラリ」にしない だけです。UTF-8はバイトパターンが構造的に厳密なので、errors="strict" でデコードが通ればまずUTF-8で確定します。統計的な推論より、規格上の厳密性に先に頼った方が外しません。
ついでに、is_binary の判定(NULバイトの有無)の前にUTF-16 BOMをチェックするようにもしました。ASCII文字が多いUTF-16ファイルは構造上 \x00 が1バイトおきに並ぶので、素朴なNUL判定だと丸ごとバイナリ扱いされて読まれません。
つまずき③:v2にしてもまだ結果が変わらない
修正後にもう一度突き合わせたところ、結果は変わりませんでした。
「エンコーディング起因だ」と決め打ちしていた仮説が崩れます。こういうとき、AIに「もっといろんなパターンを試しましょう」と続けてもらうのは時間の無駄で、事実を見せる診断ツールを作る方が早いです。
AIに「ヒットしないファイル1つを与えると、何が起きているか全部吐き出す診断スクリプトを作って」と頼んで生まれたのが fastgrep_debug.py です。1ファイルに対して、次を順に出します。
- 読めるか/サイズはいくつか
- UTF-16 BOMの有無、先頭8KBのNULバイト有無(バイナリ判定の根拠)
- UTF-8として厳密デコードできるか
-
chardetが何と判定しているか(信頼度込みで) - 最終的にどのエンコーディングが採用されるか
- デコード後のテキストにパターンが含まれるか
- 含まれないなら、生バイト列としてファイル内に存在するか
- 存在しないなら、cp932 / euc-jp / utf-16 でエンコードした場合に存在するか
そして、ヒットしないファイル1つに対して実行した結果がこちらです。
=== fastgrep 診断ツール ===
検索文字列: SampleErrorClass
対象ファイル: <省略>
[OK] ファイル存在
[OK] 読み込み成功 (約9KB)
- UTF-16 BOM: なし
- NULバイト(先頭8KB): なし
[OK] テキストファイルと判定
- BOM: なし
- UTF-8厳密デコード: 成功
- chardet判定: {'encoding': 'ascii', 'confidence': 1.0, 'language': 'en', 'mime_type': 'text/plain'}
- 最終判定エンコーディング: utf-8
[OK] デコード成功 (約9000文字)
--- マッチ行 ---
[NG] ★マッチ行が0件です★
→ UTF-8バイト列としてもファイル内に存在しません
「UTF-8バイト列としてもファイル内に存在しない」。ファイルは正しく読めているし、デコードも成功している。単にその文字列がそのファイルに無いだけです。
ということは差分の理由は文字コードでも並列処理でも無く、比較元の検索条件そのものが違うということです。落ち着いて確認すると、比較元のエディタがデフォルトで 大文字小文字を区別しない設定 だったのが真因でした。自作ツール側に -i オプションを足して再実行したら、結果が完全一致しました。
この一件で得た教訓は、「ありそうな原因」を相手にコードをいじるより、「事実をぶちまけてくれる診断ツール」を作る方が早いということです。chardet修正のv2は確かに正しい修正でしたが、今回の差分の真因ではありませんでした。診断ツール無しで進めていたら、ありもしないエンコーディング地雷をさらに3〜4個踏んでいたはずです。
並列検索10本まとめ実行:実務での効き目
数日後、別の調査で 10種類の検索文字列を一気にかけたい という場面が来ました。たとえば [EVENT_LOG_<種別A>_TypeX、[EVENT_LOG_<種別A>_TypeY …といった具合に、命名規則だけ違う文字列が10本並んでいるケースです。
これも、AIに「この配列を fastgrep のコマンド列に展開して順に実行して」と頼むだけで完了しました。ここで地味に大事なのが、AIが最初に出してきたコマンドで またWindowsパスのバックスラッシュ問題に引っかかった ことです。一度学んだはずの罠でも、別文脈で再発します。ツール側で警告を出す、ラッパーバッチを置く、といった改善ネタが自然に湧いてきます。
10本ぜんぶ実行してそれぞれ GREP_<検索文字列>.txt に保存し、件数集計まで出してもらいました。「使い捨ての検索ツール」が、命名規則を絞った定型調査のためのバッチ処理として、しれっと業務に居座り始めた瞬間です。
設計判断のまとめ
最後に、再利用しやすいエッセンスだけ箇条書きで残しておきます。
-
chardetを最初に呼ぶな:UTF-8厳密デコードを先に試して通れば即UTF-8。chardetは最後の保険で良い - UTF-16 BOMはバイナリ判定の前にチェック:NULバイト素朴判定だと丸ごとバイナリ扱いされる
- 並列化は閾値で自動切替:ファイル数が少ない時はプロセス起動コストで逆に遅くなる
- 使い方ドキュメントもAIに同時生成させる:自分が翌日忘れる罠への保険
- 動かない時の診断ツールもセットで作らせる:「ありそうな原因」を相手にする前に「事実」を見せる方が早い
- 検索条件側の前提を疑う:大文字小文字や正規表現エスケープ、改行コードなど、ツール内部より先にユーザー側の設定を確認した方が当たることが多い
おわりに
最後に、この一件で得た学びを3つだけ。
- 「ありそうな原因」よりも「事実を出すツール」: 仮説検証はAIに任せず、状態を吐き出すスクリプトを作らせる方が早い
- ツール本体と利用ガイドはセット: 自分が翌日に忘れる前提で、AIにドキュメントも同時生成させる
- ツールの外側を最後に疑わない: 結果がズレたとき、まず疑うべきは検索条件側(大文字小文字、エスケープ、改行コード)
AIで自作ツールを作る作業は、「車輪の再発明」というよりも「自分のワークフローにぴったりはまる車輪を15分で削り出す」感覚です。道具を作るコストが暴落した今、エディタやIDEの標準機能に不満があるなら、「もっと良いツールを探す」より「自分用に作る」方が速い場面が増えていきます。手元の「いつもの遅い処理」を1個、AIに作り直してもらうところから試してみてください。
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、
幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。