はじめに
RaccはRubyで書かれたパーサジェネレータです。
Raccの構文に沿って文法を書くと、パーサを生成することができます。
最近、nginx設定ファイルの設定内容(ディレクティブと値)をDB管理に移行するため、
設定値を抜き出す必要がありRaccを使ってパーサを書きました。
※設定が少なければ手動で抜き出せばよいのですが、管理しているサーバー数が多く設定ファイルが大量にあったのでパーサを書くことにしました。
Raccの使い方
本記事ではRaccの基本的な使い方は記載しません。下記記事が参考になります。
- http://qiita.com/lnznt/items/ecbc16cae8ab9c641391
書籍では、Raccの作者である青木峰郎さんの著書であるRubyを256倍使うための本 無道編がおすすめです。
解説が分かりやすいだけでなく、語り口が軽妙でC拡張3分クッキング(謎)などもあり読み物としても楽しいです。
残念ながら新品を手に入れるのは難しそうですが、中古ならAmazonでも手に入るようです。
nginx設定ファイルについて
nginx設定ファイルのサンプルとして下記を見てみます。
パーサを作成するためには、パースする対象の文の構造について理解する必要があります。
まずは、このファイルがどんな構造になっているか見ていきます。
http {
index index.html index.htm index.php;
access_log logs/access.log main;
server {
listen 80;
location ~ \.php$ {
fastcgi_pass 127.0.0.1:1025;
}
}
}
基本的な構造は次のパターンAおよびパターンBです。
<パターンA>
ディレクティブ
設定値(省略可)
セミコロン
# パターンA
error_log logs/error.log;
error_log
がディレクティブ、logs/error.log
が設定値、;
がセミコロンです。
<パターンB>
ディレクティブ
設定値(省略可)
{
パターンAまたはパターンBの繰り返し
}
# パターンB
http {
# パターンA
index index.html index.htm index.php;
access_log logs/access.log main;
# パターンB
server {
# パターンA
listen 80;
# パターンB
location ~ \.php$ {
# パターンA
fastcgi_pass 127.0.0.1:1025;
}
}
}
パターンBは{
と}
に囲まれたブロックの中にパターンB自身が登場するため、再帰構造になっている点がポイントです。
この設定ファイルの中から、ディレクティブとその設定値およびブロックの中身を上手く抜き出すにはどうしたらよいでしょうか?
正規表現によるマッチングで頑張っても何とかなりそうですが、再帰構造があるので結構複雑なコードになってしまいそうです。
このような再帰などがあって複雑な構造の文を解析するのにRaccが役に立ちます。
スキャナを書く
パースする対象についてある程度理解できたら、次にスキャナを書きます。
スキャナとはパース対象の文字列を読み込んでトークン※の並びに変換するものです。
※ディレクティブ、設定値、セミコロン、"{"、"}"など。Raccでは「:KEY」のようにRubyのシンボルでトークンを表現する。
スキャナのコードは下記のようになっています。
def parse(f) # fにはパース対象の文字列が入る
@q = []
key_found = nil
f.each do |line|
until line.empty? do
unless key_found
case line
when /\A\s+/ # 空白を読み飛ばす
;
when /\A#.*/ # コメントを読み飛ばす
;
when /\A[^;\s{}]+/ # ディレクティブ
@q.push [:KEY, $&]
key_found = 1
when /\A[{}]/ # "{"または"}"
@q.push [$&, $&]
else
raise RuntimeError, "must not happen"
end
else
# ディレクティブが見つかったあとは以下の処理
case line
when /\A\s+/
;
when /\A#.*/
;
when /\A[^;\s{}]+[^;{}]*[^;\s{}]/ # 設定値
@q.push [:VALUE, $&]
when /\A[^;\s{}]/ # 設定値(1文字)
@q.push [:VALUE, $&]
when /\A;/ # セミコロン
@q.push [:EOS, :EOS]
key_found = nil
when /\A{/ # "{"
@q.push [$&, $&]
key_found = nil
else
raise RuntimeError, "must not happen"
end
end
# 次の1文字を取得
line = $'
end
# 行末
@q.push [:EOL, :EOL]
end
# スキャン終了
@q.push [false, '$']
# パース処理の開始
do_parse
end
# パーサがトークンを取り出す時に使用する
def next_token
@q.shift
end
パース対象の文字列を1行ずつ読み込んで、正規表現でマッチングし、@q
に[シンボル, マッチした値]
という配列で追加しています。
key_found
を使ってディレクティブが見つかったかどうかでマッチング処理を変更しています。
これは、nginxの設定ではディレクティブと設定値を区切るための文字というものがなく、
最初に登場した文字列がディレクティブになるためです。
スキャナの処理が完了すると、@q
にパース対象文字列から変換したトークンの並びが含まれることになります。
最後に、do_parse
というメソッドを実行するとパース処理が開始します。
文法を書く
次に、Raccが生成するパーサの元となる文法を書きます。
前述した「ディレクティブ 設定値 セミコロン」のような規則をRaccが理解できるように書いていきます。
文法のコードは下記です。
class CFParser
rule
file : kv_list opteol
kv_list : opteol KEY val EOS
| opteol KEY val block
| kv_list opteol KEY val EOS
| kv_list opteol KEY val block
block : '{' kv_list opteol '}'
val :
| VALUE
| val eols VALUE
eols : EOL
| eols EOL
opteol :
| eols
end
例えば、「ディレクティブ 設定値 セミコロン」の規則はopteol KEY val EOS
と表現されています。opteol
はディレクティブの前に空行がある場合を想定して入っています。
「ディレクティブ 設定値 { パターンAまたはパターンBの繰り返し }」のような再帰構造は次のように表現しています。
kv_list : opteol KEY val EOS
| opteol KEY val block
# 中略
block : '{' kv_list opteol '}'
block
は'{'と'}'で囲まれた中にkv_list
が入っています。そして、kv_list
にはblock
が含まれています。
opteol KEY val EOS
を前述のパターンA、opteol KEY val block
をパターンBと考えると分かりやすいかと思います。
アクションを書く
実は、文法ファイルを書いただけではパーサは完成しません。
今回の目的は、「ディレクティブと値を取り出す」ことなので、文法にマッチした時にその処理(アクション)を実行してあげる必要があります。
文法ファイルにアクション({
と}
で囲まれた処理)を追加したものが下記です。
class CFParser
rule
file : kv_list opteol
kv_list : opteol KEY val EOS
{
result = [ [ val[1], val[2], nil ] ]
}
| opteol KEY val block
{
result = [ [ val[1], val[2], val[3] ] ]
}
| kv_list opteol KEY val EOS
{
val[0].push [val[2], val[3], nil]
}
| kv_list opteol KEY val block
{
val[0].push [val[2], val[3], val[4]]
}
block : '{' kv_list opteol '}'
{
result = val[1]
}
val :
| VALUE
| val eols VALUE
{
result = val[0] + "\n" + val[2]
}
eols : EOL
| eols EOL
opteol :
| eols
end
例えば、下記アクションはKEYの値(val[1])、valの値(val[2])、nilの配列を作成して、result
という変数に代入しています。
ここで、「KEYの値」といっているのはスキャナで[シンボル, マッチした値]
という配列を作った時の「マッチした値」に該当するものです。
resultがkv_list
の値となり、最終的にfile
のval[0]であるkv_list
の値がfile
の値になります。(file
のようにアクションが定義されていない場合はval[0]がその規則の値になる)
そして、file
の値がパーサの返却値、つまりdo_parse
メソッドの返却値になります。
file : kv_list opteol
kv_list : opteol KEY val EOS
{
result = [ [ val[1], val[2], nil ] ]
}
パーサを実行する
スキャナとパーサ(の元となる文法)が完成したので、パーサを生成してnginx設定ファイルをパースしてみます。
# パーサを生成
bundle exec racc conf_parse.y -o parse.rb
# パースを実行
ruby exec.rb sample_nginx.conf
# パース結果が表示される
[["error_log", "logs/error.log", nil],
["http",
nil,
[["index", "index.html index.htm index.php", nil],
["access_log", "logs/access.log main", nil],
["server",
nil,
[["listen", "80", nil],
["location", "~ \\.php$", [["fastcgi_pass", "127.0.0.1:1025", nil]]]]]]]]
exec.rb
はファイルをオープンしてパーサに渡すことと、結果の表示だけをやっています。
require './parse.rb'
require 'pp'
parser = CFParser.new
begin
File.open(ARGV[0]) do |f|
conf_list = parser.parse(f)
pp conf_list
end
rescue Racc::ParseError => e
$stderr.puts e
end
パース結果を見てみると、当初の目的である「ディレクティブと値を取り出す」が達成されていることがわかります。
ブロックがない場合は配列の3番目の要素にnil
が入り、ブロックがある場合はブロック内の設定が入ります。
おわりに
Raccを使用することで比較的低コストでnginx設定ファイルの解析をすることができました。
RaccはYaccという有名なパーサジェネレータを元に作成されており、文法の書き方はほとんど同じです。
YaccはCRubyインタプリタでも使われているので、CRubyのソースコードに興味がある方は
Yacc入門としてRaccを使ってみるのもいいのではないかと思います。
なお、作成したnginx設定ファイルのパーサはgithubで公開していますので、よかったら使ってみてください。