こんにちは、スタンバイで求人の取り込みシステムを開発・運用をしている鈴木です。 今回は Scala と Go に標準で組み込まれている正規表現エンジンの違いについてです。
概要
スタイバイでは Scala で書かれたシステムを Go にリプレイスする開発が進んでいます。 その中で Scala で実装されている正規表現が Go だと動かない事象に遭遇しました。その違いはどんなものがあるのか?まとめます。
機能差分の一例
今回発見した機能差分の例を見てみましょう。 スタンバイで扱っている求人情報を管理するために正規表現を使って色々な情報を抽出しています。 例としてこんな感じの求人情報から給与の情報を抽出したいとします。 (簡単化のため金額のカンマを削除しています。)
◎看護師(正・准)/時給1400円~1600円+交通費 月収例 246400円~281600円+交通費※20日勤務、1日8h 日勤帯のみの場合◎ヘルパー(2級以上)・介護福祉士/時給1000円~1200円+交通費 月収例 176000円~211200円+交通費※20日勤務、1日8h 日勤帯のみの場合※深夜勤(22:00~翌5:00)は時給25%アップ※日勤帯のみでも相談に応じます
数字を基準に抽出すれば良いのですが、単純に数字をマッチしてしまうと 1日8h
や 22日勤務
なども抽出してしまうので、それらを除外しましょう。
そうするとこんな感じの正規表現で実現できます。
[1-9]+[0-9\.十百千万億\s ]*(?!\d*[分|時|日|月|年|h|級])
ざっくりどんなマッチになるかと言いますと、前半の [1-9]+[0-9\.十百千万億\s ]*
の部分は数値と金額の単位をマッチしています。
後半の (?!\d*[分|時|日|月|年|h|級])
は前半のマッチのなかでも 分|時|日|月|年|h|級
の字が続く場合は除外しています。
この ?!
で表されている マッチしないこと の動きが Go の正規表現では対応しておらずエラーになってしまいます。
error parsing regexp: invalid or unsupported Perl syntax: `(?!`
これは Go の正規表現エンジンが否定先読みの機能をサポートしていないためです。 このように正規表現には正規表現を解釈して実行するエンジンがいくつかあり、サポートしている機能に差があります。
バックトラッキングについて
Go で標準ライブラリを使用した正規表現は RE2 エンジンで動きます。 RE2 は他の正規表現エンジンと比較してバックトラッキングを行わない特徴があります。 否定先読みができなかったのもこれが関連しているわけですね。 このバックトラッキングを行わないことで多彩な機能は使えないものの、処理時間が線形時間で動作しメモリの使用量も抑えることができます。
一方、Scala で scala.util.matching.Regex を使った場合は java 標準の正規表現が使われます。 こちらはバックトラッキングをサポートしてるので RE2 と比べて否定先読みのように複雑なマッチングができます。
バックトラッキングの様子は こちら などのサイトで正規表現のデバッグをすると確認しやすいです。 デバッグモードは PCRE 系の正規表現エンジンのみ対応しているようなので PCRE2 で見てみましょう。
画面上部の REGULAR EXPRESSION の入力に正規表現を、その下の TEST STRING にマッチさせたい文字列を入力します。 そして、左メニューの FLAVOR で PCRE2 を選び Regex Debugger からデバッグができます。
Match1 を進めていくと ◎看護師(正・准)/時給1400円
のうち 1400
の部分にマッチして1つ目が完了しています。
Match2 から Match4 までも同様に 1600
, 246400
, 281600
にマッチしています。
Match5 を進めると 20日
の 20
にマッチしていますが、その後に 20
の後に 日
がマッチしないことを確認している様子がわかります。
さらに進めると今度は先頭の2を除外して 0
まで戻った後、再び 日
がマッチしないことを確認しています。
このようにバックトラッキングを利用すると同じ箇所に何度もマッチするか試行してしまうため、場合によっては指数関数的に処理量が増加し、それに応じて必要なメモリ量も増えてしまいます。 そのため RE2 では処理量が線型増加していくという性質はパフォーマンスに関わってくるのが分かりますね。
その他の機能比較
バックリファレンス
バックリファレンスは一度マッチしたグループを再利用できます。
こんな正規表現 (\b\w+at\b).*\1
でこんな文字列 The cat sat on the mat with another cat.
をマッチしてみると...
\1
の部分が初回マッチした cat
となることで末尾の cat
にマッチしていることがわかります。
ゼロ幅マッチ
正規表現上でも特別な意味を持つメタ文字があります。 ^
は文字列の先頭 $
は末尾にマッチするなどですね。基本的には Go でもゼロ幅マッチが利用できますが利用できないパターンがいくつかあります。
例えばこんな正規表現 cat(?=\s)
でこんな文字列 The cat sat on the mat with another cat.
をマッチしてみると...
マッチの条件に空白は含んでいますがマッチ結果には含まれていないことがわかります。
条件付きの正規表現
条件をつけて満たす場合のパターンと満たさない場合のパターンの分岐をする機能です。
(?(test) true| false)
のように ?(条件)
と true/false のパターンといった記述になります。
こちらは java の正規表現でもサポートされていません。
フォワードリファレンス
バックリファレンスの逆で後のキャプチャグループを参照できるらしいです。 が、マッチ試行していない部分を参照するという特殊な挙動なので利用できる正規表現エンジンはかなり限られるようです。 こちらも java, go ともにこの機能は使えません。
他の正規表現エンジンを Go で使う方法
このように Go の標準パッケージを利用すると、Scala では利用できていた一部の正規表現の機能が利用できなくなりました。 Go から標準の RE2 以外にも他の正規表現エンジンを利用する方法があるようですが、日本語対応に怪しいところがあるようです。
package main import ( "fmt" "github.com/GRbit/go-pcre" ) func main() { pattern := pcre.MustCompile(`[1-9]+[0-9\.十百千万億\s ]*(?!\d*[時|日|月|年|%|h|級])`, 0) subject := "◎看護師(正・准)/時給1400円~1600円+交通費 月収例 246400円~281600円+交通費※20日勤務、1日8h 日勤帯のみの場合◎ヘルパー(2級以上)・介護福祉士/時給1000円~1200円+交通費 月収例 176000円~211200円+交通費※20日勤務、1日8h 日勤帯のみの場合※深夜勤(22:00~翌5:00)は時給25%アップ※日勤帯のみでも相談に応じます" matcher := pattern.NewMatcher([]byte(subject), 0) for matcher.Matches { fmt.Printf("GroupString: %s\n", matcher.GroupString(0)) indices := matcher.Index() end := indices[1] subject = subject[end:] matcher = pattern.NewMatcher([]byte(subject[end:]), 0) } }
正規表現のパターンに 分
が含まれている場合に、入力文字列に含まれていなくてもマッチ結果が変わりました。また、マッチした文字列が文字数単位ではなくバイト単位で切り取られて文字化けしてしまう事象もありました。
現在取り組んでいるリプレイスの開発は求人情報から特定の情報を抽出するロジック全体も見直しつつ開発する方針で進めているため、正規表現エンジンに関しては外部ライブラリは採用しませんでした。
終わりに
このように正規表現のエンジンは複数あり様々な特性を持っています。
今回は Scala(Java) と Go の正規表現の機能についてのみの調査になりましたが、動作速度のベンチマークを基準に比較するのも面白そうです。
必要な要件に合わせて適切な正規表現エンジンが選択できるようになれるといいですね!
スタンバイでは、常に新しいアイデアや技術を調査し、試しています。新しいことに挑戦したい方や、素晴らしいプラットフォームで素晴らしい仲間と仕事をしたい方は、ぜひ採用ページをご覧ください!
スタンバイのプロダクトや組織について詳しく知りたい方は、気軽にご相談ください。 www.wantedly.com