論理と、幻想と。

FFXIVやPCが好きなITスペシャリストの屁理屈とか

ACTや補助輪で使う正規表現の話

ACTのカスタムトリガーやスペスペのトリガーで初めて正規表現に触れた方のためのざっくりした解説です。トリガーが意図した通りにマッチングしなかったり、遅延が発生したり、といった問題改善の一助となれば幸いです。

正規表現とは

記号や文字列の組み合わせによって、任意の文字列を表現する形式の一つです。

ja.wikipedia.org

特に行単位で発生するデータストリームから、探したい文字列を効率的に検索する手法としてズバ抜けて優れているため、特にログ解析などで汎く用いられています。サーバ運用やWeb開発などをする人であれば知らない人はいないんじゃないかと思います。ACT・補助輪でも、ACTが生成する膨大な量のログの中から任意の文字列を検索するためのツールとして使われています。

正規表現の処理をWeb上で再現できるシミュレータは色々ありますが、私はこちらのサービスをよく使います。本エントリで話題に挙げるシミュレータは全てこちら。

regex101.com

反復学習ソフト付き 正規表現書き方ドリル (WEB+DB PRESS plus)
杉山 貴章
技術評論社
売り上げランキング: 242,624

特殊文字

正規表現の作法で最初に引っかかるのが特殊文字だと思います。よく使う特殊文字とその意味合いをざっくり羅列します。

記号 意味
. 任意の一文字にマッチする
^ 行の先頭にマッチする
$ 行の末尾にマッチする
[...] 指定した文字列のグループにマッチする
例:[A-Za-z] ならアルファベット大文字小文字いずれか1文字にマッチ
例:[1234] なら1~4の数字のどれか1文字にマッチ
* 直前の文字列の0回以上の繰り返し
+ 直前の文字列の1回以上の繰り返し
? 直前の文字列の0回または1回の繰り返し
{n} 直前の文字のn回の繰り返し
{n,m} 直前の文字のn回以上、m回以下の繰り返し
| OR、グルーピングされた文字列を区切る
( ) 文字列のグルーピング
例:(黒渦団|双蛇党|不滅隊)
\ エスケープシーケンス、直後の特殊文字を通常の文字列として扱う
\. は ピリオド一文字にマッチ
\s 空白文字1文字にマッチ
\S 空白以外の1文字にマッチ
\d 数字1文字にマッチ
\D 数字以外の1文字にマッチ
\w アルファベット、数字、一部の記号1文字にマッチ
\W アルファベット、数字、一部の記号以外の1文字にマッチ
\t タブ文字にマッチ
\n 改行にマッチ

位置関係

相談を受けているうち、「マッチングしない」または「意図しないところでマッチングする」という現象のほとんどが、正規表現による位置関係の追記または修正で片付いています。

厳密すぎてマッチングしない例

マッチング文字列が ^1B で始まるようなトリガーに対して、チャットでマッチング文字列を入力してテストしようとした場合。当然マッチングしません。先頭の ^ によってトリガーがネットワークログの区分である 1B から始まることを期待しているのに対し、チャットログは 00 から始まるためです。

ガバガバすぎて意図しないマッチングが起こる例

極端な例ですが、バトルリタニーの使用をキャプチャするために 「バトルリタニー」 というマッチング文字列を指定した場合。バトルリタニーを使用したタイミングだけでなく、ターゲットがバトルリタニーの効果を受けたログもマッチングします。ついでに、「バトルリタニー」の効果が切れた、というログもマッチングします。効果終了時にもマッチングしてしまう、というのは位置関係の定義ひとつで解決できます。

バトルリタニーを使用したログのみにマッチングしたいのであれば、マッチング文字列を 「バトルリタニー」$ とすればOKです。「バトルリタニー」 で終わる行のみにマッチし、以降に文字列が続く場合にはマッチングしません。

マッチングの範囲

先にエノキアン使用時のサンプルログを。

15:1003C150:Regexp Tester:DF7:エノキアン:1003C150:hoge hoge:3C:DD8000:0:0:0:0:0:0:0:0:0:0:0:0:0:0:93616:93616:9200:10000:0:1000:100.5784:112.706:0:-3.112917:93616:93616:9200:10000:0:1000:100.5784:112.706:0:-3.112917:00006738

戦闘中はこういうログがガンガン出力されます。


任意の文字列を表現するために .*.+ という書き方がよく使われます。

. は「何でもいい一文字」にマッチし、* は「直前の文字の0回以上の繰り返し」です。例えば .*: と記述した場合、: が出現するまでの任意の文字列にマッチします。これらは最長一致です。検索対象文字列(ログ一行)のうち、もっとも長く当てはまる部分にマッチします。

サンプルログに対して.*: のマッチングを試行すると、15:1003C150:Regexp Tester:DF7:エノキアン:1003C150:hoge hoge:3C:DD8000:0:0:0:0:0:0:0:0:0:0:0:0:0:0:93616:93616:9200:10000:0:1000:100.5784:112.706:0:-3.112917:93616:93616:9200:10000:0:1000:100.5784:112.706:0:-3.112917: がマッチします。

ACTや補助輪だとほとんど使いませんが、.*? のように? を書き足すと最短一致になります。

正規表現の処理コストの評価

正規表現を使用したトリガーが多数アクティブになっているとCPUに大きな負荷がかかります。

ACTのバトルログなんかをチラっと見てみると分かるんですが、とてもじゃないけれど人類には早すぎると言わざるを得ないほどに多くのログが出力されています。戦闘中などは顕著で、秒間数十行~数百行にも及びます。これらのログ一行ずつ、トリガーの数だけ、それぞれマッチング文字列に一致するかどうかを確認していくわけです。

正規表現のマッチング処理には負荷が掛かる、ということと同時に、最も処理コストが掛かるのは文字列に「マッチングしないこと」を確認する点であることに留意すると良いかと思います。100万行のログから1行のお目当てのログを探し出す、ということは、残り99万9999行のログに対して「マッチングしないこと」を確認する処理を行っているということです。

詳しい仕組み等を話し始めたら果てしないのですが、こちらのエントリが非常に詳しいので、興味がある人は読んでみてください。

postd.cc

実際にあった話を引用

これまた極端な例ですが、ある時フォーラムで、トリガーを追加したあと、ACTが非常に重くなり遅延が発生するようになった、という相談がありました。

その人は「Sモブの情報がチャットログに流れてきたら通知する」という仕組みを作ろうとしていたようで、そのトリガーを消したら遅延は解消したとのことでした。そのトリガーのマッチング文字列がこんな感じでした。

.+:.+(S|Sランク|クロック・ミテーヌ|ケロゲロス|ガーロック|ボナコン|ナンディ|チェルノボーグ|レドロネット|ウルガル|マインドフレア|サウザンドキャスト・セダ|ゾーナ・シーカー|ブロンテス|バルウール|ヌニュヌウィ|ミニョーカオン|サファト|アグリッパ|ガンダルヴァ|セーンムルウ|ペイルライダー|レウクロッタ|極楽鳥|カイザーベヒーモス|ソルト・アンド・ライト|ガンマ|オルガナ|オキナ|ウドンゲ|ボーンクローラー|アグラオペ|イシュタム|グニット|フォーギヴン・ペダントリー|ティガー|タルキア|フォーギヴン・リベリオン)

ぱっと見て、「Sモブの通知をしようとしている」というのが分かります。けれどここには大きな罠が隠れていました。

行頭の .+: は、前述の通り、ログ文字列の中で:が出現するまでの最も長い部分にマッチングしようとします。その後、: に続けて一文字以上空けた場所に、Sモブ名のグループの中に一致する文字列があるかを探します。しかし : の後の .+ も可能な限り長くマッチングしようとします。結果的に行末に行き着きます。行末から逆方向に一文字ずつログを読み進め、グループ内のどれかに一致するかを探し、マッチングしなければ次は一つ前の : を探し、再度行末から一文字ずつ走査していくことになります。こんな感じで処理のステップ数がどんどん増えていきます。

サンプルとして、唐突ですがブリザガを使います。こんなログが出ます。

00:082b:Regexp Testingの「ブリザガ」

この文字列が上記のトリガー文字列に「マッチングしないこと」を確認するのに、Webシミュレータでは 8018 ステップかかりました。

また、先に上げたエノキアン使用時の長いサンプルログに至っては、それ一つが上記トリガー文字列に「マッチングしないこと」を確認するのに、シミュレータで 14352009 ステップ、6秒かかりました。WebシミュレータとACTがログ処理に使うCPUコアの処理速度を同列に語ることはできませんが、とにかく処理のステップ数が多く時間がかかる、というのが伝われば良いです。

秒間数十行も数百行も出力されるバトルログの全ての行に対して、膨大なコストをかけて「マッチングしないこと」の確認が行われるわけですから、遅延が発生するのはとても自然なことです。

しかし、位置関係を書き加えることで、アプリケーション側が「マッチングしないこと」を早々に判断できるようにしてやれば、一気に処理コストは下がります。

この件に関して、私が代替案として書き換えたマッチング文字列が以下です。

^00:001[0-8]:\S+\s\S+:.*(S$|Sランク|クロック・ミテーヌ|ケロゲロス|ガーロック|ボナコン|ナンディ|チェルノボーグ|レドロネット|ウルガル|マインドフレア|サウザンドキャスト・セダ|ゾーナ・シーカー|ブロンテス|バルウール|ヌニュヌウィ|ミニョーカオン|サファト|アグリッパ|ガンダルヴァ|セーンムルウ|ペイルライダー|レウクロッタ|極楽鳥|カイザーベヒーモス|ソルト・アンド・ライト|ガンマ|オルガナ|オキナ|ウドンゲ|ボーンクローラー|アグラオペ|イシュタム|グニット|フォーギヴン・ペダントリー|ティガー|タルキア|フォーギヴン・リベリオン) 

第1フィールドをチャットログのログ区分である 00 と指定し、第2フィールドにはLS1~8に対応する 001[0-8] を指定しています。第3フィールドでキャラクター名のキャプチャを試行します。「リンクシェルで誰かが発言したとき」にだけ、その後のチャット文字列がグループ内に含まれる文字列群と一致するかどうかの評価を行います。

先のエノキアン使用時の長いサンプルログに関しては、そもそも 00 で始まっていないので、最初の1文字を評価した時点でマッチングしていないことが分かります。シミュレータでは、「マッチングしないこと」の確認にかかる処理コストは 14352009 ステップから2ステップまで短縮できました。

私は実際にこの条件式のトリガーを使っていないので実際にどうかは分かりませんが、マッチングしないことが早々に分かる上に、通常のLSチャットメッセージのように途中まで一致するような文字列であってもおよそ数百~~数千ステップ程度の評価で済むので、処理の遅延は大幅に低減するはずです。

[改訂新版]正規表現ポケットリファレンス
宮前 竜也
技術評論社
売り上げランキング: 492,447