メインコンテンツへスキップ
#Web制作 約6分で読めます

正規表現のバリデーションが「1回おきに」失敗する謎バグ、犯人はgフラグだった

同じ入力なのにtest()がtrue/falseを交互に返す本番バグの原因はgフラグのlastIndexだった。lookbehindでページ全体が死んだ話も含め、JS正規表現のハマりどころと自作テスターを紹介。

フォームの郵便番号バリデーションが「たまに」通らない、という報告を受けた。再現手順を聞くと「同じ値をもう一回送信したら通った」と言う。同じ入力で結果が変わるバリデーションなんてあるわけがない——と思いながらコードを開いたら、あった。

const ZIP_RE = /^\d{3}-\d{4}$/g;

function isZip(value) {
  return ZIP_RE.test(value);
}

一見どこも悪くない。でもこれ、実際に動かすとこうなる。

isZip('100-0001'); // true
isZip('100-0001'); // false ← 同じ入力なのに
isZip('100-0001'); // true
isZip('100-0001'); // false

きれいに1回おきに失敗する。ユーザーの「もう一回送ったら通った」は正確な観察だった。

gフラグ付きの正規表現は「状態」を持つ

原因は、正規表現リテラルの末尾に何気なくついていたgフラグ。

gフラグ付きの正規表現オブジェクトはlastIndexというプロパティを持っていて、test()exec()を呼ぶたびに前回マッチした位置の続きから検索を始める。定数として使い回しているZIP_REは、呼び出しをまたいでこの状態を持ち越す。

1回目のtest()はマッチしてlastIndexが8に進む。2回目は文字列の8文字目(=末尾)から検索を始めるので当然マッチせず、失敗するとlastIndexが0にリセットされる。だから3回目はまた成功する。真偽が交互に入れ替わるのはこの仕組みのせいだ。

直し方は単純で、マッチの有無を見るだけならgを外す。全件検索したい場面(matchAllや置換)以外でgをつける理由はない。どうしてもg付きを使い回すなら呼ぶ前にre.lastIndex = 0だが、そんなコードを書きたい人はいないと思う。

たちが悪いのは、単体テストでisZip('100-0001')を1回だけ呼ぶと普通に通ってしまうこと。バグが顕在化するのは「同じ正規表現オブジェクトで2回以上判定したとき」だけなので、テストは緑のまま本番に出ていく。うちではまさにそのコースだった。

lookbehindで「その行を実行していないのに」ページ全体が死んだ話

もうひとつ、別の現場での話。金額表記から数字だけ抜き出すのに後読み(lookbehind)を使った。

const price = text.match(/(?<=¥)[\d,]+/);

Chromeでは完璧に動いた。リリース後、iOSの一部ユーザーから「ページが真っ白」という報告が来た。

当時のSafari(16.4より前)は後読みに未対応で、これが厄介なのは実行時エラーではなくパース時のSyntaxErrorになること。正規表現リテラルはスクリプトの読み込み時点で構文解析されるので、その行が一度も実行されないコードパスにあっても、ファイル全体のJSが1行も動かなくなる。try-catchで囲んでも無意味だ(catchに到達する前にパースで死んでいるので)。

今のSafariは後読みに対応済みだが、古いWebViewを抱えたアプリ内ブラウザではまだ踏むことがある。lookbehindを使うときは、対象環境を確認するか、new RegExp('(?<=¥)[\\d,]+')のように文字列から構築してパース時死亡だけは避ける、という選択肢を覚えておくと保険になる。

「JSのエンジンで」試せる場所が欲しくて、テスターを作った

この2件でだいぶ時間を溶かして痛感したのが、正規表現は書いた環境と同じエンジンで確認しないと意味がないということ。有名なオンラインテスターの多くはデフォルトがPCRE系のフレーバーで、JSと微妙に挙動が違う。あと、本番ログの断片を貼ってデバッグしたいとき、外部サービスのテキストエリアに貼るのは毎回ためらいがあった。

それなら自分のブログに置けばいい、ということで作ったのがこれ。

正規表現テスター

ブラウザのJavaScriptエンジンをそのまま使ってマッチ判定するので、「テスターでは動いたのに本番で動かない」というフレーバー差の事故が起きない。パターンを打つそばからマッチ箇所がハイライトされて、名前付きキャプチャグループ(?<name>...)の中身も一覧で見える。置換プレビューでは$1$<name>が使えるので、String.replaceに渡す前の確認がその場で済む。

処理は全部ブラウザ内で完結していて、テキストはどこにも送信されない。ログや顧客データを含む文字列でも気兼ねなく貼れる。メールアドレスや電話番号、日付といった定番パターンを10個並べてあるので、ゼロから書かずにクリックして直すところから始められる。

正規表現を本番に出す前のチェック

冒頭のバグ以来、正規表現を含むPRでは自分に課していることが3つある。

  1. マッチ有無の判定にgがついていないか見るtest()gの組み合わせはほぼ確実に事故る
  2. 同じ入力で2回連続呼ぶテストを書く。lastIndex系のバグはこれだけで捕まえられる
  3. lookbehind・名前付きグループなど比較的新しめの構文は、対象ブラウザで一度実行する。パース時SyntaxErrorはtry-catchをすり抜ける

そして書いたパターンは、コードに貼る前にテスターに実際のデータの断片を入れて、ハイライトが想定どおりの範囲だけ光るかを目で確認する。正規表現は「マッチする」ことより「マッチしすぎない」ことの確認のほうが大事で、それはハイライトで見るのが一番早い。

半日溶かして得た教訓としては安いのか高いのか分からないが、少なくともgフラグを見る目だけは確実に変わった。

#正規表現 #JavaScript #デバッグ #自作ツール
シェア