Rubyの隠れた宝石「StringScanner」で文字列解析をマスターする
Rubyは楽しく書ける言語であるだけでなく、優秀な標準ライブラリも魅力のひとつです。その中にはあまり知られていない、いわば「隠れた宝石」と呼べるものがいくつかあります。今回はゲストライターのMichael Kohl氏が、そのお気に入りのひとつであるStringScannerを紹介します。
StringScannerとは何か
Rubyでは、OpenStructやSetのようなデータ構造からCSVパース、ベンチマークまで、サードパーティのgemをインストールしなくてもかなりのことが実現できます。しかし、標準で同梱されているライブラリの中には、あまり知られていないながらも非常に便利なものがいくつか存在します。そのひとつがStringScannerです。ドキュメントによると、これは「文字列に対する字句スキャン操作を提供する」ライブラリです。
スキャンとパースの基本
では、「字句スキャン(lexical scanning)」とは具体的に何を指すのでしょうか。端的に言えば、入力文字列から一定のルールに従って意味のある情報のかたまりを取り出す処理のことです。たとえばコンパイラの最初の段階では、2 + 1のような式を入力として受け取り、次のようなトークンの列に変換します。
[{ number: "2" }, {operator: "+"}, { number: "1"}]
字句スキャナは通常、有限状態オートマトンとして実装されており、ANTLRやRagelのように自動生成してくれる有名なツールも複数あります。
しかし、パースの要件がそこまで高度でない場合もあります。そんなとき、正規表現ベースのシンプルなライブラリであるStringScannerが非常に役立ちます。StringScannerは、文字列内のインデックスにすぎないスキャンポインタの位置を記憶しながら動作します。スキャン処理では、スキャンポインタの直後にある文字列が指定した正規表現にマッチするかどうかを試みます。マッチング操作に加えて、スキャンポインタを前後に移動させるメソッド、ポインタを動かさずに先の内容を覗き見るメソッド、現在位置が行頭・行末・文字列全体の末尾などどこなのかを調べるメソッドなども提供されています。
実践:Railsのログをパースしてみよう
理論はこのくらいにして、StringScannerを実際に使ってみましょう。次の例では、以下のようなRailsのログエントリを取り上げます。
log_entry = <<EOS
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
Processing by HomeController#index as HTML
Rendered text template within layouts/application (0.0ms)
Rendered layouts/_assets.html.erb (2.0ms)
Rendered layouts/_top.html.erb (2.6ms)
Rendered layouts/_about.html.erb (0.3ms)
Rendered layouts/_google_analytics.html.erb (0.4ms)
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
EOS
これを次のようなハッシュにパースすることを目標にします。
{
method: "GET",
path: "/",
ip: "127.0.0.1",
timestamp: "2017-08-20 20:53:10 +0900",
success: true,
response_code: "200",
duration: "79ms"
}
補足:StringScannerの良い練習例ではありますが、実運用ではLogrageとそのJSONログフォーマッタを使う方が適切でしょう。
準備:requireと初期化
StringScannerを使うには、まずrequireが必要です。
require 'strscan'
続いて、ログエントリをコンストラクタに渡して新しいインスタンスを生成します。同時に、パース結果を格納するための空のハッシュも定義しておきます。
scanner = StringScanner.new(log_entry)
log = {}
スキャナのposメソッドを使うと、スキャンポインタの現在位置を取得できます。予想どおり、結果は文字列の最初の文字を指す0です。
scanner.pos #=> 0
処理の流れを追いやすくするため、ポインタの位置を可視化してみましょう。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
スキャナの状態をさらに詳しく調べるには、beginning_of_line?とeos?が使えます。これにより、スキャンポインタが現在行頭にあり、まだ入力を消費し終えていないことを確認できます。
scanner.beginning_of_line? #=> true
scanner.eos? #=> false
HTTPメソッドの抽出
最初に取り出したい情報はHTTPリクエストメソッドです。「Started」という単語とスペースの直後にあります。名前そのままのskipメソッドを使ってスキャンポインタを進めましょう。このメソッドはスキップした文字数を返します。今回の場合は8です。また、matched?で期待どおりに処理できたかを確認できます。
scanner.skip(/Started /) #=> 8
scanner.matched? #=> true
これでスキャンポインタはリクエストメソッドの直前に移動しました。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
次にscan_untilを使って実際の値を取り出します。このメソッドは正規表現にマッチした部分全体を返します。リクエストメソッドはすべて大文字なので、1文字以上にマッチする単純な文字クラスと+演算子で対応できます。
log[:method] = scanner.scan_until(/[A-Z]+/) #=> "GET"
この操作の後、スキャンポインタは「GET」の最後の「T」の位置にあります。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
パスの抽出
続いてリクエストされたパスを取り出します。スペースを1つスキップし、ダブルクォートで囲まれた部分を抽出すればよいわけです。やり方はいくつかありますが、そのひとつがキャプチャグループ(正規表現内の括弧で囲まれた部分、つまり(.+))を使う方法です。これは任意の1文字以上にマッチします。
scanner.scan(/\s"(.+)"/) #=> " \"/\""
ただし、ここではscanの戻り値を直接使うのではなく、capturesで最初のキャプチャグループの値を取得します。
log[:path] = scanner.captures.first #=> "/"
パスの抽出に成功し、スキャンポインタは閉じのダブルクォートの位置に来ました。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
IPアドレスの抽出
ログからIPアドレスをパースするには、再びskipで前後をスペースで囲まれた「for」という文字列を無視し、続いてscan_untilで1文字以上の非空白文字にマッチさせます(\sは空白文字を表す文字クラスで、[^\s]はその否定です)。
scanner.skip(/ for /) #=> 5
log[:ip] = scanner.scan_until(/[^\s]+/) #=> "127.0.0.1"
さて、スキャンポインタが今どこにあるかわかりますか? 少し考えてから、下の答えと比べてみてください。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
タイムスタンプの抽出
タイムスタンプのパースは、もうおなじみの手順です。まず信頼のskipで「 at 」という文字列を無視し、続いてscan_untilで現在行の終わりまで読み込みます。行末は正規表現では$で表されます。
scanner.skip(/ at /) #=> 4
log[:timestamp] = scanner.scan_until(/$/) #=> "2017-08-20 20:53:10 +0900"
ステータスコードと成功判定
次に知りたい情報は、最終行にあるHTTPステータスコードです。skip_untilを使って、「Completed」という単語の後のスペースまで一気に進めます。
scanner.skip_until(/Completed /) #=> 296
名前が示すとおり、このメソッドはscan_untilと似ていますが、マッチした文字列の代わりにスキップした文字数を返します。これでスキャンポインタは目的のHTTPステータスコードの直前に置かれました。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
^
さて、実際にHTTPレスポンスコードを読む前に、そのコードが成功(この例では2xx台)を表すのか失敗(それ以外)を表すのかを判定できると嬉しいと思いませんか? これを実現するために、peekを使ってスキャンポインタを動かさずに次の文字を覗いてみます。
log[:success] = scanner.peek(1) == "2" #=> true
そしてscanで次の3文字を読み取ります。正規表現では/\d{3}/と表せます。
log[:response_code] = scanner.scan(/\d{3}/) #=> "200"
ここでもスキャンポインタは、直前にマッチした正規表現の末尾に位置します。
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
^
処理時間の抽出
ログエントリから最後に取り出したいのは、ミリ秒単位の実行時間です。「 OK in 」という文字列をskipで読み飛ばし、続けて「ms」という文字列まで(それ自体を含めて)読み込めば完成です。
scanner.skip(/ OK in /) #=> 7
log[:duration] = scanner.scan_until(/ms/) #=> "79ms"
これで最後のピースも揃い、目標としていたハッシュが完成しました。
{
method: "GET",
path: "/",
ip: "127.0.0.1",
timestamp: "2017-08-20 20:53:10 +0900",
success: true,
response_code: "200",
duration: "79ms"
}
まとめ
RubyのStringScannerは、単純な正規表現と本格的な字句解析器(レキサー)の中間に位置する、絶妙な立ち位置のツールです。複雑なスキャンやパースのニーズには最良の選択肢ではないかもしれません。しかし、その素直な設計のおかげで、基本的な正規表現の知識があれば誰でも入力文字列から情報を抽出でき、筆者も過去に本番コードで問題なく活用してきました。皆さんもぜひ、この隠れた宝石を見つけてみてください。
PS:次に取り上げてほしい「隠れた宝石」があれば、ぜひ教えてください!
-
知る人ぞ知る実用RubyGem 7選!Railsプロジェクトをレベルアップする隠れた名品
Railsプロジェクトで使える最高のRuby gemには、どんなものがあるのでしょうか? この記事では、その答えをご紹介します! 今回は7つのgemを取り上げますが、何度も目にした定番のgemではありません。とても役立つのに、意外と知られていないgemをお届けします。 ただ、その前にひとつだけ… 注意点があります。 何でもかんでもgemに頼ってしまう開発者を見かけることがあります。 「もしかすると役立つかも」と思うだけで、gemを導入してしまうのです。 そのgemが本当に自分の抱える問題を解決するのか、最適な選択肢なのか、メンテナンスやドキュメントは整備されているのか——立ち止まって考えること
-
Ruby 2.6の新機能9選|コード例でわかる注目ポイントを徹底解説
Ruby 2.6には、開発者の生産性を高める新しい機能やパフォーマンス改善が多数盛り込まれています。 本記事では、Ruby 2.6で導入された9つの注目新機能を、実際のコード例とともにわかりやすく紹介します。最新のRuby動向をキャッチアップしたい方は、ぜひ最後までご覧ください。 1. 無限Range(Endless Range) Ruby 2.5以前でもFloat::INFINITYを使えば終端のない範囲を表現できましたが、Ruby 2.6ではさらに直感的な記法が使えるようになりました。 新しい無限Rangeは次のように書きます。 (1..) 通常のRangeが(1..10)のように終端