正規表現の実践パターン:アンカー・量指定子・キャプチャグループ
要約 (TL;DR)
2019年7月2日、Cloudflareがたった1つの正規表現——WAFの新ルールに入った .*(?:.*=.*) のようなほとんどマッチしかけて失敗するパターン——のせいで、27分間グローバルトラフィックが50%近く落ちた。バックトラックが爆発し、単一コアが100%で詰まって全リクエストが止まった。あの事件が正規表現について教えてくれる一番正直なことはこうだ——正規表現は自分のコストをよく隠す。普段は小さく速くて魅力的だが、「不正な入力1行」でシステムが止まりうる。だからこの記事のトーンは少し懐疑的だ。実務の正規表現はだいたい少数の部品の上に立つ。先頭・末尾・単語境界を指すアンカー、「この文字のいずれか」を表す文字クラス、「何回」を決める量指定子、そしてマッチをキャプチャしたり参照したり選択肢を束ねるグループ。うまく使えばメールの大ざっぱマッチ、ログからのフィールド抽出、電話番号の正規化のような典型問題が短くて読めるパターンで解ける。下手に使えばマッチしすぎたりしなさすぎたり、上の事例のようにエンジンを止める。HTML・JSON・XMLは正規表現でパースしない。正規言語は釣り合いの取れた入れ子を記述できない。エンジン差も大きい——ECMAScript、PCRE(Perl/PHP)、Python re、Go RE2は先読み・後方参照・Unicode処理が違い、Cloudflareの事件もPCREのバックトラックモデルが原因だった(Go RE2なら起きなかった)。
背景・コンセプト
正規表現は文字列マッチのための小さな文法だ。よく使う部品を名前で分けて整理する。
アンカーは文字を消費せず位置を主張する。^ は入力の開始(マルチライン時は行頭)、$ は末尾、\b は単語境界。アンカーなしの abc は長い文字列のどこにでもマッチするが、^abc$ は文字列全体が abc のときだけ、\babc\b は独立した単語 abc のときだけマッチする。
文字クラスは1文字が「どの集合に属するか」を表す。[abc] は a、b、c のいずれか、[a-z] は小文字、[^0-9] は「数字以外すべて」。よくあるケースには略記がある。\d(数字)、\w(単語文字——文字、数字、下線)、\s(空白)。大文字 \D、\W、\S は補集合。. は既定で改行を除く任意の文字にマッチし、s(dotall)フラグがこの挙動を変える。
量指定子は直前のトークンに付いて反復回数を決める。* は0回以上、+ は1回以上、? は0または1回、{n,m} は「n〜m回」(各境界は省略可)。既定は貪欲(greedy):できるだけ多くマッチした後、後ろが失敗するときだけ1つずつ譲る。? を付けて遅延(lazy)にすると——*?、+?、??——できるだけ少なくマッチし、後ろが強制すれば拡張する。一部のエンジンは所有(possessive)量指定子(*+、++)もサポートし、貪欲にマッチしつつ失敗しても譲らない。これは病的な入力でのバックトラック爆発を防ぐ——上のCloudflareの事例で正に欠けていた道具だ。
グループは部分パターンを囲む。(pattern) はキャプチャグループで、開き括弧の順に1から番号が付き、パターン内では \1 で、ホスト言語からはインデックスアクセサで参照できる。(?<name>pattern) は名前付きキャプチャ。(?:pattern) は非キャプチャグループで、純粋に選択((?:cat|dog))や量指定子の囲い込みのために使われ、マッチを記録しない。グループ内の | は選択(alternation)。
最後にフラグがエンジンの動作を調整する。i は大文字小文字無視、m はマルチライン(^・$ が行境界でもマッチ)、s はdotall、u はUnicode。JavaScriptで u フラグは \p{L}(「すべての文字」)のようなUnicodeプロパティエスケープも有効にする。
比較・データ
| 量指定子 | 貪欲(既定) | 遅延(? 後置) | 所有(対応時) |
|---|---|---|---|
* / *? / *+ | できるだけ多く、失敗時に譲る | できるだけ少なく、強制されれば拡張 | できるだけ多く、譲らない |
+ / +? / ++ | 1回以上、貪欲 | 1回以上、遅延 | 1回以上、譲らない |
? / ?? / ?+ | 0–1、1選好 | 0–1、0選好 | 0–1、譲らない |
{n,m} / {n,m}? / {n,m}+ | 範囲、貪欲 | 範囲、遅延 | 範囲、譲らない |
貪欲が既定なのは大体それが正しいから。遅延が必要なのは「後ろのパターン」が緩すぎるとき。HTMLの抜粋から <b>...</b> の1組を取り出すには <b>(.*?)</b> のように遅延を使わないと複数のタグを飲み込む(それでも本物のHTMLには正規表現を使うな)。所有量指定子と原子グループ (?>...) はほとんどマッチしかけて失敗するパターンで指数的な逆探索を断ち切るときに有効だ。PCRE/Java/RubyのOniguruma はこれをサポートし、ECMAScriptとPython re は対応していない(Pythonは3.11から re モジュールに原子グループを追加した)。
実践シナリオ
シナリオ1 — 実務的なメールマッチ。 RFC 5322の完全なメール文法はコメント・引用済みローカルパート・IPリテラル入れ子まで許すので、これを全部覆う正規表現は悪名高く巨大(最も有名な試みは6,425文字)で、それでも本物のパーサにはならない。僕が運用で使ったパターンはほぼ常に ^[^\s@]+@[^\s@]+\.[^\s@]+$——「空でない、空白なし、@ が変な場所にない、ドメインに点が1つはある」程度。明らかな打ち間違いを弾くには十分で、アドレスが本当に存在するかは1通送ってみないとわからない。形は正規表現で、存在はメールで。
シナリオ2 — 国際フォーマットを含む電話番号。 +82 2-1234-5678、(02) 1234-5678、82-2-1234-5678 は同じソウルの番号の異なる表記だ。^\+?\d{1,3}[-\s().]*\d{1,4}[-\s().]*\d{3,4}[-\s().]*\d{3,4}$ のようなパターンで一般的な区切りを受け、後段の正規化で区切りを除いて数字だけ残した正準表現にするのが一般的だ。ルーティング・保存・ダイヤルのような本当に重要な処理にはGoogleのlibphonenumberを使う。あるチームで見た最速の会議結論は「正規表現ではやらない」で、その日1週間分の技術的負債を防いだ。正規表現の役割は「電話番号らしいか」の表面検査だけだ。
シナリオ3 — ログ1行からフィールド抽出。 2026-04-13T02:11:05Z 192.0.2.42 "GET /search?q=foo HTTP/1.1" 200 1534 のような行は1つのパターンで分解できる。^(?<ts>\S+)\s+(?<ip>\S+)\s+"(?<method>\w+)\s+(?<path>\S+)\s+\S+"\s+(?<status>\d+)\s+(?<bytes>\d+)$。ここで名前付きグループの価値が現れる。結果のマッチオブジェクトは辞書のようにアクセスでき、各フィールドが名前で取り出せる。ログフォーマットが変わっても、このパターン自体がそのフォーマットのドキュメントの役割をする。
よくある誤解
「正規表現でメールを完全検証できる」。 形までだ。RFC 5322は妥当に正規表現にエンコードできる範囲を超えており、エンコードしたとしても「アドレスの形が合っている」が「メールボックスが存在する」ことを意味しない。業界標準は簡単な正規表現 + 確認メールだ。
「貪欲は常に遅延より遅い」。 事実ではない。量指定子の部分パターンが非常に制限的なら、貪欲マッチが1回の長い前進で終わるので速い場合もある。遅延が勝つのは <b>(.*?)</b> のように後ろのパターンがマッチをアンカリングしてくれるとき。反射的に ? を付ける前に実際の入力でベンチマークしよう。
「正規表現エンジンはみな同じ」。 違う。ECMAScriptには所有量指定子と原子グループがなく(v フラグでやや改善された)、Python re は独自のUnicodeプロパティ集合を持ち、PCREは後方参照・再帰パターンまでサポートする。RubyとRustの onig クレートが使うOnigurumaはまた別の派生だ。GoのRE2は後方参照と先読みを諦める代わりに、入力長に対して線形な実行時間を保証する——Cloudflareが事件後に移行を検討した正にそのエンジンだ。Perlのチュートリアルからコピーしたパターンが JavaScript で動かないのはよくある話。
「正規表現でHTML(あるいはJSON、XML)をパースできる」。 正規言語は釣り合いの取れた入れ子を記述できない。特定の属性値のようなよく定義された部分パターンを抽出するところまでは正規表現が有効だが、ツリー全体を正しくパースすることはできない。入れ子フォーマットは専用パーサ(DOMParser、JSON.parse、XMLライブラリ、CSVリーダー)を使うべき。Stack Overflowの「正規表現 vs HTML」伝説(2009年の回答)は議論ではなく警告だ。
チェックリスト
- 入力は何で、反例は何か? 両方ともパターンを書く前に書き出しておく。
- データが入れ子・再帰構造か? ならばパーサを使う。正規表現は道具を間違えている。
- どのエンジンを対象にするか? JS・Python・Go・PCREは先読み・後方参照・Unicodeで違う。
- マッチ自体が必要か、Yes/Noで足りるか? 量指定子や選択のためだけのグループは
(?:...)非キャプチャに。 - パターンがユーザー入力か、信頼できない入力に適用されるか? 原子グループ・タイムリミット・RE2のような線形時間エンジンで、爆発的バックトラックを防ぐ。Cloudflareみたいになる。
- 後段のテキスト正規化があるか? 全部を1パターンに詰め込まず、単純な形チェックと軽い後処理に分ける。
- 正規表現はドキュメント化されているか?
x(extended)モードのコメントや上に1行の説明を添えるだけでも、次の人にとって大きな保険になる。
関連ツール
Patrache Studioの 正規表現テスター はサンプル入力にパターンを当ててキャプチャをインラインで見せるので、タブを行き来するより速い。マッチ対象がログ内のJSONのような構造化文字列なら JSONのフォーマット・構文検証・JSON Schemaの違いと実務での使い分け と組み合わせて、抽出した断片を2つ目の正規表現ではなくまともなパーサで検証してほしい。正規表現のよくある標的の1つがURL内のUUIDで、UUID v1・v4・v7の比較とDB主キー設計 で、同じ36文字の文字列がバージョンビットによってなぜ別の意味を持ちうるかを確認できる。
参考文献
- MDN, “Regular Expressions” ガイド — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions
- IETF RFC 5322, “Internet Message Format”(メール文法) — https://datatracker.ietf.org/doc/html/rfc5322
- regex101, エンジン選択可能なインタラクティブテスター — https://regex101.com/
- Google, RE2 — 線形時間正規表現エンジン — https://github.com/google/re2/wiki/Syntax