正規表現はいわば開発者のツールボックスにおけるスイス製アーミーナイフのようなものであり、ほぼ必ずと言っていいほど、そのとき取り組んでいる作業に関するよりよい正規表現というものが存在します。優れた正規表現の構築は反復的なことが多く、エッジケースを含めた興味深い新規データを追加すればするほど、その質と信頼性は増していきます。

きちんと機能する正規表現はそれで十分であることも多く、もしデータがきわめて予測可能であれば、正規表現の最適化は要らぬ労力かもしれません。しかし、ひとたび正規表現をより広域の大規模システムの一部として、もしくは信頼性の低いデータセットにおいて使用し始めると、その信頼性とレジリエンス、パフォーマンスの質を確保する必要性は高まります。

正規表現は一見複雑そうに見えますが、ひとたび理解してみれば、そのシステムはロジカルで予測可能です。ただ、複雑な正規表現のリバースエンジニアリングはあまり楽しいものではありません。

今回のブログでは、重要なユースケースに正規表現をどのように組み込むかについて見ていきます。具体的には、ログ管理の重要な要素であることが多い、ログ行からの名前と値のペアの抽出を行います。ログは、どんなときに強力な正規表現が必要なのかを考えるよい例です。なぜなら、通常、ログはより広範なシステム(理想的にはスタック全体のログを得られる)の一部であり、アプリケーションに合わせて拡張が必要であり、一貫性がないことが多いからです。では、さっそくいくつかの正規表現を見ていきましょう。そのなかで、他に使用する正規表現の強化についても学んでいければと思います。

ログ行を正規表現で解析する

このユースケースは、New Relicでお客様のログ解析をサポートするために、実際の要件に基づいてできたものです。New Relicには、生のログデータを取り込んで個別の有意義な列に解析できる、強力なデータ解析メカニズムがあります。

実際のユースケースには以下が必要になります。

  • ログデータに、他のデータとともに複数の名前と値のペアが含まれる
  • そのペアは(attr=value)の形式で示される
  • 値がホワイトスペースを持ちうる
  • すべての名前と値のペアを収集する必要はない
  • すべてのログ行に含まれるペアもあるが、そうではないペアもある
  • ペアは順不同で現れる

以下が、ログ行の例です。

my favourite pizza=ham and pineapple drink=lime and lemonade venue=london name=james buchanan

このデータ例において、例えばデータからpizzadrinknameフィールドを抽出したいとします。 しかし、ログ行のvenueデータやその他のデータは抽出したくありません。さらに複雑なことに、このデータを多くのログ行から収集したい、しかもデータは常に一貫して現れるわけではないとしたらどうでしょうか?どんな正規表現なら、これらの値を取得できるのでしょうか?

結論:その正規表現について

おそらくGoogle検索でここに辿り着いた方は、すぐにコピー&ペーストで使えるルールをお探しではないでしょうか。それがこちらの、=記号で区切られた名前と値のペアを抽出する正規表現です。

(?:^|\s+)(?=.*?attrname=(?<attrname>[^=]+?(?=(?:\s+\b\w+\b=|\s*?$))))?

ちなみにGrokバージョンはこちらです。

(?:^|\s+)(?=%{DATA}attrname=(?<attrname>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?

これらのルールには以下が適用されます。

  • すべてのキーの値のペアが存在する必要はない。ルールは存在するキーの値のペアで機能するが、キーの値のペアのいくつかが行に存在しなくても違反にはならない
  • キーの値のペアの順序は問題にならない
  • 値内のホワイトスペースは許容される

ルールがどのように機能するかについては、ぜひ続きを読んでみてください。

Grokでの解析

このセクションでは、Grokバージョンのルールを取り上げます。こちらのほうが若干簡潔だからです。また、New Relicでの解析ルールはGrokで書かれているので、既存の名前があるGrokパターンを使用することができます。Grokは正規表現にもとづいているため、有効な正規表現はすべて有効なGrokの表現でもあるということになります。Grokをお使いでなければ、前セクションで説明した標準の正規表現バージョンを使用してください。

脆弱な解析ルールから始める

何らかのデータを使って正規表現のテストを開始しましょう。私はビールとピザが大好物で、薪釜のオーブンまで持っているので、ここではピザがテーマのデータセットを使うことにします。

1: my favourite pizza=ham and pineapple drink=lime and lemonade name=james buchanan

2: my favourite drink=lime and lemonade name=james buchanan pizza=ham and pineapple

3: my favourite name=james buchanan pizza=ham and pineapple drink=lime and lemonade 

4: my favourite pizza=ham and pineapple drink=lime and lemonade

5: my favourite name=james buchanan pizza=ham and pineapple foo=bar drink=lime and lemonade

6: my favourite drink=lime and lemonade

ご覧の通り、このデータセットには異なる順序のキーの値のペアと様々な数のホワイトスペースが含まれ、しかもキーの値のペアの数も異なっています。 

このデータ例では、各行のキーの値のペアはdrink=cokeというように、イコール=記号で区切られています。ここで、3つの値、すなわちpizzadrinknameを抽出したいとしましょう。

もしデータが常に1行目に出現するのであれば、以下のように、それぞれの値を抽出するGrok解析ルールを作成できるでしょう。

pizza=(?<pizza>%{DATA})drink=(?<drink>%{DATA})name=(?<name>%{GREEDYDATA})

これは機能はしますが、ルールは脆弱です。ここでは値が常に同じ順序で出現する必要があります。もしいずれかの値が不足したり、余計なデータが加わると、ルール全体が失敗します。これは望ましくありません。うまくマッチしないためにデータが欠けてしまうのは避けたいところです。たとえデータが一貫性を保っていたとしても、100%の確証などありえるでしょうか?

もしこれをNew Relicのビルトインログ解析テストツールで試してみたければ、Logs > Parsing > Create parsing ruleをお使いください。サンプルのログ行にこのルールをペーストして、アウトプットを確認することができます。もしくは、このGrok toolを使用してGrokルールを試してみることもできます。

先読みルールを使用する

では、この解析ルールをどうやってもっと堅牢なものにできるでしょうか。ここでは、先読み(lookahead)の使用が役立ちます。ひとつのキーの値のペアをターゲットにするには、2つのことを知る必要があります。いつマッチを開始し、いつ終了するかです。順を追って見ていきましょう。

値のペアを見つける

このピザの値のペアを例に取りましょう。これは常にpizza=のように始まります。このパターンは一貫しているため、先読みして以下のようなテキストをキャプチャできます。 

(?=%{DATA}pizza=(?<pizza>.*))

これにより以下が返されます。

pizza: ham and pineapple drink=lime and lemonade name=james buchanan

DATAは、.*?の表現と等しくなります。ここで役立つGrokパターンのリストはこちらでご覧ください。この先読みルールはpizza=の文字列の後をすべて検索し、pizzaというフィールドにキャプチャします。これは機能するものの、いっぽうでドリンクと名前の値もキャプチャされます。そのため、このルールでは、文字とホワイトスペースのキャプチャを次の名前の値のペアのみに制限する必要があります。

必要な属性のみを取得する

pizzaの値のみをキャプチャするために、別のlookaheadを使用できます。以下のルールでは、イコール記号ではないすべての文字をキャプチャします。これは最短マッチ(non-greedy)で、[^=]+のパターンに? が付加されます。この後にはホワイトスペース文字、単語、それから別のイコール記号が続きます。ルールは以下の通りです。

(?=%{DATA}pizza=(?<pizza>[^=]+?(?=(?:\s+%{WORD}=))))

1行目に返されるのは:pizza:ham and pineapple

しかしながら、2行目に返されるのは:一致なし!❌

ずっとよくなりましたが...いったんストップです。2行目で、ピザのマッチが失敗しています。これはなぜでしょう?

このパターンでは、別の名前の値のペアが続くデータをマッチしますが、このケースでは、ルールは行全体を検索するものの、そこに別の名前の値のペアがないのです。キャプチャするには、後ろに別の名前の値のペアもしくは行末が来る必要があり、これは$で示されます。また、ホワイトスペースの追跡を考慮することも重要で、これは非貪欲の%{SPACE}?で排除できます。

以下が、更新されたパターンです。

(?=%{DATA}pizza=(?<pizza>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))

1行目に返されるのは:pizza:ham and pineapple

2行目に返されるのは:pizza:ham and pineapple

これで格段に改善され、信頼性も増しました。もしひとつのフィールドをキャプチャしたいだけなら、これで完成です。しかし、ログにおいては、複数のフィールドをキャプチャする必要があることも多いでしょう。

ログの複数フィールドをキャプチャする

複数の表現をつなげて同じ表現を繰り返し、必要に応じて値の名前を変えることで、他の値を取得することができます。

(?=%{DATA}pizza=(?<pizza>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))(?=%{DATA}drink=(?<drink>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))(?=%{DATA}name=(?<name>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))

これにより、以下が返されます。

1行目:pizza:ham and pineapple, name:james buchanan and drink:lime and lemonade

2行目:1行目と同じ✅

3行目:1行目と同じ✅

4行目:マッチなし!❌

ここでは、サンプルデータの1行目から3行目まではうまく機能します。このルールでは、キーの値のペアの順序に関わらず、マッチを返します。残念ながら、この入力の4行目では失敗します。

4: my favourite pizza=ham and pineapple drink=lime and lemonade

4行目ではnameキーが欠落していることにお気づきでしょうか。この正規表現ルールでは、nameが存在している必要があり、それがないとパターン全体が失敗します。これは、データセットで正規表現を使用する際に気づかないことも多く、よくある失敗です。ご想像の通り、この類の問題は対応が非常にややこしくなりかねません。ルールは正しく機能しているように見えるのに、重要な情報を収集できないからです。これは、各パターンをオプションにすることで修正できます。そのためには、各表現の最後に?を付加します。

以下が、各キーの値のペアの一般化されたパターンです。

(?=%{DATA}attrname=(?<attrname>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?

この正規表現をデータで試してみましょう。以下の表現には3倍のパターンが含まれており、それぞれがキャプチャされる必要のある各属性(namepizzadrink)のために設定されています。

(?=%{DATA}pizza=(?<pizza>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?(?=%{DATA}drink=(?<drink>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?(?=%{DATA}name=(?<name>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?

これにより、以下が返されます。

1行目:pizza:ham and pineapple, name:james buchanan and drink:lime and lemonade✅ 

2行目:1行目と同じ✅

3行目:1行目と同じ✅

4行目:pizza:ham and pineapple, drink:lime and lemonade

5行目:1行目と同じ✅

6行目:drink:lime and lemonade

このルールなら、すべてのテスト入力データをどんな順序でも正しくマッチし、さらに欠落しているフィールドに対しても機能します。

正規表現の先読みパフォーマンス

先読み(lookahead)は、オーバーヘッドの追加的パフォーマンスであるため、もしデータの一貫性が確実であれば、先読みのない、よりシンプルでよりパフォーマンスの高いルールを使用することができるかもしれません。また、ルールの冒頭にプレフィックス (?:^|\s+) を追加することで、このルールのパフォーマンスを格段に高めることができます。

(?:^|\s+)(?=%{DATA}pizza=(?<pizza>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?(?=%{DATA}drink=(?<drink>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?(?=%{DATA}name=(?<name>[^=]+?(?=(?:\s+%{WORD}=|%{SPACE}?$))))?

この小さな変更により、オーバーヘッドが行の冒頭でのみ起こる、もしくは余白がある場合にはすべての文字では起こらないようにできます。そうすると、ルールは不要な箇所では先読みを使用しないようになります。

まとめ

このブログをお読みになり、このルールがどう機能するのか、またどのようにルールの反復的な改善を行い、信頼性を高めていけるかについてご理解いただけたなら幸いです。じっくりと熟考してみれば、必ずよりよい正規表現は見つかります。皆さんが、ご自身のユースケースにおいて、さらに効果的な表現を見つけられることを祈っています!