正規表現
この部の 3 / 8 章 ・ 全体で 7 / 76 章 ・ 読了目安 35 分
- 他人が書いた正規表現を分解して読める
- ログ抽出と一括置換を安全に実行できる
- 正規表現を使うべきでない場面を判断できる
前章のコマンドラインで、すでに正規表現を使いました。
grep -E 'ERROR|WARN' app.logこの ERROR|WARN が正規表現です。正規表現は文字列のパターンを書くための小さな言語で、
grep・sed・エディタの置換・バリデーション・ログ検索と、あらゆる場所に出てきます。
正規表現は「書ける」より先に**「読める」ことのほうが重要です。
他人が書いた ^\d{4}-\d{2}-\d{2}$ を見て怯まないこと、
そして正規表現を使ってはいけない場面を知ること**が、この章のゴールです。
1文字を表す
正規表現は、基本的には書いた文字がそのままその文字にマッチします。
abc → "abc" という並びにマッチ
そこに、特別な意味を持つ文字(メタ文字)を混ぜていきます。
| 書き方 | 意味 | 例 |
|---|---|---|
. | 改行以外の任意の1文字 | a.c は abc axc にマッチ |
[abc] | a か b か c のどれか1文字 | [aeiou] は母音1文字 |
[^abc] | a b c 以外の1文字 | [^0-9] は数字以外 |
[a-z] | 範囲指定 | [a-zA-Z0-9] は英数字 |
\d | 数字1文字([0-9] と同じ) | |
\w | 単語構成文字([a-zA-Z0-9_]) | |
\s | 空白文字(スペース・タブ・改行) | |
\D \W \S | 大文字は「その否定」 | \D は数字以外 |
これを知らないと、複数行のテキストを扱う時にハマります。
'line1\nline2'.match(/line1.line2/) // null(マッチしない)
'line1\nline2'.match(/line1.line2/s) // マッチする(s フラグ)s フラグ(dotall)を付けると . が改行にもマッチします。
言語によって呼び名が違い、Go では (?s) を先頭に書きます。
繰り返しを表す
直前の1つが「何回繰り返されるか」を指定します。
| 書き方 | 意味 |
|---|---|
* | 0回以上 |
+ | 1回以上 |
? | 0回か1回(あってもなくてもよい) |
a{3} | ちょうど3回 |
a{2,4} | 2〜4回 |
a{2,} | 2回以上 |
\d{4}-\d{2}-\d{2} 2026-08-08 のような日付
https?:// http:// または https://
colou?r color と colour の両方
https? の ? は「直前の s が0回か1回」という意味です。
? がかかるのは直前の1文字だけ、というのが読み間違えやすいところです。
位置を表す
文字ではなく「位置」にマッチする記号があります。
| 書き方 | 意味 |
|---|---|
^ | 行の先頭 |
$ | 行の末尾 |
\b | 単語の境界 |
^ERROR 行頭が ERROR
\.log$ .log で終わる
\bcat\b 単語としての cat(category にはマッチしない)
コードの一括置換で最も事故が多いのが、部分一致による巻き添えです。
user を account に置換すると、username が accountname に、
userId が accountId に変わってしまいます。
\buser\b と書けば、単語として独立した user だけが置換されます。
グループと選択
( ) でまとめ、| で「どちらか」を表します。
(cat|dog) cat または dog
(ab)+ ab の繰り返し(ababab)
^(GET|POST) /api GET か POST で始まり /api が続く
( ) にはマッチした部分を後から取り出すという役割もあります。これがキャプチャです。
const m = '2026-08-08'.match(/(\d{4})-(\d{2})-(\d{2})/);
m[0]; // '2026-08-08' マッチ全体
m[1]; // '2026' 1つ目の ( ) の中身
m[2]; // '08'置換では $1 $2(sed では \1 \2)で参照できます。
'2026-08-08'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1');
// '08/08/2026'# ログの「日付 レベル メッセージ」を並べ替える
sed -E 's/^([0-9-]+) (\w+) (.*)$/[\2] \3 (\1)/' app.logまとめたいだけでキャプチャは不要な時は (?:...) を使います。
(?:https?|ftp)://(\S+)
こう書くと $1 が URL 本体になります。(https?|ftp) にすると
$1 がプロトコル、$2 が本体とずれるため、後の番号がずれて事故ります。
貪欲マッチ — 最初に必ずハマるところ
* と + はできるだけ長くマッチしようとします。これを貪欲(greedy)と言います。
'<b>hello</b> world'.match(/<.*>/)[0];
// '<b>hello</b>' ← 期待は '<b>' なのに、最後の > まで取られる.* が「改行以外なら何でも」なので、最後の > まで飲み込んでから引き返すためです。
? を付けると最短マッチ(非貪欲・lazy)になります。
'<b>hello</b> world'.match(/<.*?>/)[0];
// '<b>'より安全なのは、そもそも飲み込ませない書き方です。
'<b>hello</b> world'.match(/<[^>]*>/)[0];
// '<b>' ← 「> 以外」しか読まないので、行き過ぎようがない.* は「何でもいい」という意味なので、書いた本人の意図より広くマッチします。
「ここには来ないはずの文字」を除外した文字クラス([^>]* [^"]* [^,]*)に
置き換えられないか、まず考えてください。
読みやすくなるだけでなく、後述する ReDoS の予防にもなります。
実務で使うレシピ
覚えるより、必要になったら調べて組み立てられることが大事です。 よく使う形だけ挙げておきます。
# ログから ERROR 行だけ、前後3行つきで見る
grep -E -C3 'ERROR' app.log
# ステータスコードが 5xx のアクセスログだけ
grep -E ' 5[0-9]{2} ' access.log
# IP アドレスらしきものを抽出して集計する
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' access.log | sort | uniq -c | sort -rn
# TODO コメントを、書いた人つきで一覧する
grep -rnE 'TODO\(([a-z]+)\)' --include='*.go' .
# import のパスを一括で書き換える(-i は上書き。先に -i なしで確認する)
sed -E -i '' 's|"github.com/old/pkg|"github.com/new/pkg|g' $(git ls-files '*.go')sed -i や エディタの一括置換は、取り返しがつきます(git があるので)が、
気づかないと取り返せません。
- まず
grep -nE 'パターン'で何件・どこにマッチするかを目で見る sed -E 's/.../.../g'を-iなしで実行して出力を確認する- コミットしてクリーンな状態にしてから
-iを付けて実行する git diffで差分を全部読む
git diff を読まずに置換をコミットするのが、最も事故が起きるパターンです。
方言がある
「正規表現」は1つではありません。ツールごとに書ける記法が違います。
| 種類 | 使う場所 | 特徴 |
|---|---|---|
| BRE | grep sed(デフォルト) | + ? | ( ) に \ が必要。読みにくい |
| ERE | grep -E sed -E | 上の記号がそのまま使える。基本これを使う |
| PCRE | JavaScript, Python, Java, grep -P | 先読み・後読みなど拡張が豊富 |
| RE2 | Go, RE2 系ツール | 先読み・後読みが無い。代わりに速度が保証される |
Go の regexp パッケージは RE2 という実装を使っており、
先読み (?=...) と後読み (?<=...)、後方参照 \1 をサポートしていません。
機能を削っている代わりに、入力の長さに比例した時間で必ず終わることが保証されています。 つまり後述の ReDoS が原理的に起きません。
JavaScript で書いた正規表現をそのまま Go に持ってきて動かない時は、 まずこれを疑ってください。先読みが必要な処理は、 正規表現をやめてコードで書くのが正解です。
使ってはいけない場面
正規表現は強力なので、向いていない仕事にも使えてしまうのが危険なところです。
構造のあるものをパースしない
HTML・XML・JSON・CSV を正規表現でパースしてはいけません。
これらは入れ子構造を持ちます。正規表現は原理的に「対応する括弧の入れ子」を 数えられないため、必ずどこかで破綻します。
<div><div>a</div></div> ← どの </div> がどの <div> に対応するか、正規表現には分からない
"a,b",c ← CSV のクォート内カンマを split(',') は分けてしまう
必ず専用のパーサを使ってください。
HTML なら DOM パーサ、JSON なら JSON.parse、CSV なら CSV ライブラリです。
メールアドレスを完全に検証しない
「正しいメールアドレス」を厳密に表す正規表現は、 RFC に忠実に書くと数千文字になり、それでも間違えます。
実務での正解は次の2段構えです。
// 1. 明らかな入力ミスだけを弾く(@ が1つあって、前後が空でない)
const looksLikeEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 2. 本当に届くかどうかは、確認メールを送って確かめるそのアドレスが有効かどうかは、送ってみないと分かりません。 正規表現でできるのは「明らかにおかしい入力を弾く」ところまでです。
同じ理由で、電話番号・住所・氏名を正規表現で厳しく検証するのも避けてください。 世界には自分の想定より多様な形式があります。
ReDoS — 正規表現でサービスが止まる
入れ子になった繰り返しは、入力次第で処理時間が爆発します。
const re = /^(a+)+$/;
re.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaaX'); // 帰ってこない(a+)+ は「a の並びの分け方」を総当たりで試すため、
文字数に対して指数関数的に時間がかかります。
これはユーザー入力を正規表現で検証しているサービスでは攻撃になります。 1リクエストで CPU を1コア占有できるので、少ないリクエスト数でサービスを止められます (Regular expression Denial of Service, ReDoS)。
- 繰り返しの中に繰り返しを書かない(
(a+)+(a*)*(\d+)*の形を避ける) .*ではなく[^X]*のように除外文字クラスで書く- 入力の長さに上限を設ける(先に
if (input.length > 200) return false;) - ユーザーが正規表現そのものを入力できる機能は作らない
- 迷ったら Go の RE2 のような線形時間保証のある実装を使う
CI で ReDoS を検出する Linter(ESLint の redos 系ルールなど)もあります。
ユーザーが入力した検索キーワードを、そのまま正規表現に埋め込んで検索する機能を実装しようとしています。何が問題ですか。
読み方の手順
長い正規表現に出会ったら、次の順で分解します。
^(?:https?://)?(?:www\.)?([a-z0-9-]+)\.([a-z]{2,})(?:/(\S*))?$
^と$を確認する — 全体一致か、部分一致か(で区切って塊に分ける — 上は「プロトコル」「www」「ドメイン名」「TLD」「パス」の5つ?が付いている塊は省略可能 —(?:https?://)?は無くてもよい- キャプチャ
( )を数える — 何を取り出したいのかが分かる - 具体的な入力を1つ当ててみる
読めない正規表現を書き換えるより、テストを1本書いてから触るほうが安全です。
複雑な正規表現は、x フラグ(拡張モード)で改行とコメントを入れられます。
const re = new RegExp(
[
'^(\\d{4})', // 年
'-(\\d{2})', // 月
'-(\\d{2})$', // 日
].join(''),
);JavaScript には x フラグが無いので、上のように配列で組み立てると読めます。
「1行で書ける」ことに価値はありません。半年後に読めるかどうかがすべてです。
実務の落とし穴まとめ
.*の貪欲マッチ — 想定より長く飲み込む。[^X]*か.*?にする.は改行にマッチしない — 複数行ではsフラグ /(?s)- HTML・CSV を正規表現でパース — 必ず破綻する。パーサを使う
- Go に先読みが無い — RE2 なので
(?=...)は使えない (a+)+型の入れ子繰り返し — ReDoS。入力長の上限も設ける- ユーザー入力をパターンに埋め込む — 正規表現インジェクション
- 一括置換を
git diffを読まずにコミット — 巻き添えに気づけない
まとめ
- 正規表現は読めることが第一。書くのは調べながらで構わない
.[]*+?{n,m}^$\b( )|— これだけで実務の8割- 貪欲マッチが最初の罠。除外文字クラスで書くと安全で読みやすい
- 方言がある。
grep -Eを基本にし、Go には先読みが無いことを覚えておく - 構造のあるデータはパースしない。メールアドレスは厳密検証しない
- ユーザー入力に使う時は ReDoS とインジェクションを必ず意識する
公式ドキュメント
迷ったら一次情報に戻ってください。
章末問題
アクセスログから、ステータスコードが 500 番台の行だけを抜き出したい。最も適切なのはどれですか。
`<div>` から `</div>` までを正規表現で抜き出して、中身を書き換える処理を書こうとしています。どうすべきですか。
`/^(\\w+\\s?)+$/` という入力検証をコードレビューで見つけました。何を指摘しますか。
次の章は vim です。サーバー上で設定ファイルを直す時、git commit でエディタが開いた時、
kubectl edit を打った時 —— 逃げられない場面が必ず来ます。