正規表現やpack/unpackは、設定、ログ、プロトコルメッセージなどのような 構造を持つテキストデータまたはバイナリデータの処理において非常に役立つ 機能です。 しかし、構造が複雑になると、正規表現やunpackを用いたコードを作成するこ とが難しく、また、メンテナンスも難しい場合があります。 このような困難さを克服するためのツールやライブラリとして、文法やフォー マットによって構造のあるデータを扱う際に便利な abnf, racc, syntax.rb, tdp4r, OpenSSL::ASN1 を簡単に概要だけを紹介します。
一般的に、記述された文法から、その文法やフォーマットに従うデータを、プロ グラム内で扱いやすいように変換するコードを生成するものを、パーサジェネレ ータと呼びます。 多くのパーサジェネレータはツールとして提供されており、文法を記述したファ イルを用意して、そしてそれをパーサジェネレータの入力として文法に従ったテ キストデータなどを処理するコードを生成します。 このようなパーサジェネレータは、プログラミング言語を作成する際によく利用 されていますが、設定ファイルやログファイルなどの解析に対しても有効です。 パーサジェネレータを使う利点に、文法(データの構造)と、それに対する処理を 明確に区別できます。そして、文法に見落としがないかなどのチェックを行いや すくなります。
パーサジェネレータについて議論を行うとき、まず扱える 文法の範囲1 や、解析方法2 に着目することが多いです。 しかし、これらの詳細な説明は RLR では行いませんので、詳しくは教科書[4,5] を 参照して下さい。 RLR では、扱える文法の強力さよりも、どのように文法を記述できるか、あるいは、 どのように ruby から利用できるかという点に着目します。
一方で、今回扱うツールやライブラリを、 DSL(Domain Specific Language) [1,2] の視点から見ると多少興味深いかもしれません。 DSL とは、特定ドメインのモデリングを行うための言語のことで、ドメインの専門家 がプログラムを行えることを期待しています。 このような言語を扱うツールには、yacc や xsltproc などがあります。 同様に、今回 RLR で扱うツールとライブラリが扱う対象も DSL であると言えます。3 yacc は、BNF という文法を記述する言語によってプログラミングを行います。 また、xsltproc は、XSLT という変換言語によって、XMLの変換方法をプログラミングします。 そして、DSL により記述されたモデルは、ツールなどにより汎用的な言語へ変換する か、または、その DSL のためのエンジンによって処理されます。 一部では、DSL を、External DSL と Internal DSL に分けて呼ぶことがあります。 Internal DSL は、汎用的な言語の中で定義した DSL のことで、 逆に、External DSL は、汎用的な言語の外で定義した DSL です。 この分類に従うと、abnf と racc の記述言語は External DSL であり、tdp4r と syntax.rb は、Internal DSL として実現されていると言えます。 Internal DSL は、その DSL を実装するための言語の文法などの制約を受けることがあります。 このため、一見すると分かり難い記述を強いられることもあります。 しかし、実装言語の機能を活用することもでき、記述コストなどを削減できる場合もあります。 このような利点と欠点は、ツールやライブラリの選択の基準の一つになります。
abnf は、ABNF による文法記述からその文法に一致する正規表現を生成 するライブラリです。 ABNF は、BNF と基本的に文法は同じであり、表現形式が拡張されているものです。 ABNF(あるいは似たようなBNF)を用いて文法を定義したものに、 IPアドレス 4 やURI 5 があります。 このような文法記述から、IPアドレスやURLに一致する正規表現を生成すること によって、文法に従った正確な正規表現を生成できます。
abnf は、保守性に貢献します。 例えば文法が多少変更になった場合でも、どの部分を変更すれば良いのか比較的楽に 判断できます。 ただし、ABNF により表せる文字集合と、正規表現で記述できる文字集合では、 前者の方が広い集合となります。このため、abnf では正規表現に変換できな い場合があります。少なくとも、再帰を含まない文法は正規表現に変換できます。
abnf には、IPv6とURIに関するサンプルプログラムが付属していますが、 ここでは、サンプルとして、整数の四則演算を行う算術式にマッチする正規表現 を記述してみます。 そして、文字列 str 中に現れる算術式を、その計算結果に置き換えます。 通常は、意味との一致を考慮して、再帰的な文法により定義するのですが、複雑 な再帰的な文法は abnf では扱えませんので、フォーマットだけを考えて記述 します。
require 'abnf'
fmt = <<EOS
expr = prim 0*(op prim)
prim = ["("] num [")"]
num = 1*("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
op = "+" | "-" | "*" | "/"
EOS
reg = ABNF.regexp(fmt)
str = "3+(2-1)は4です。33(23)は33と(23)に区別されます。"
puts( str.gsub(reg){|expr| eval(expr)} )
abnf を用いると、全体がマッチすることは分かるのですが、例えば、算術式となる 文字列を抽出したときに、その算術式における num に相当する文字列も同時に検出 するということなど細かい制御ができないようです。 恐らく、抽出したものに対して再びパターンマッチを行う必要がありそうです。 そこで、文法を分けて記述します。この例が、次のsample_abnf_2.rb です。
require 'abnf'
fmt = <<EOS
expr = prim 0*(op prim)
prim = ["("] num [")"]
op = "+" | "-" | "*" | "/"
EOS
numfmt = <<EOS
num = 1*("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
EOS
r1 = ABNF.parse(fmt)
r2 = ABNF.parse(numfmt)
r1.merge(r2)
reg = r1.regexp()
num = r2.regexp()
str = "3+(2-1)は4です。33(23)は33と(23)に区別されます。"
str.gsub(reg){|expr| p expr; expr.gsub(num){|n| p n} }
10,11行目の parse メソッドによって、記述した文法のパースを行います。 文法のパースの段階では、num の定義が欠けていても構いません。 もし、sample_abnf_1.rb のように regexp メソッドによって正規表現を生成 しているとエラーになります。 12行目で expr の文法に num の文法をマージします。 そして、13,14行目の regexp メソッドによってそれぞれの正規表現を生成して います。 このようにすると、文法の再利用を行うことが可能です。
以上のサンプルと同様に、ログファイルからIPアドレスとホスト名を抽出すること などにも利用できます。 抽出すること以外にも、設定ファイルの簡単な文法チェックなどにも利用できるでしょう。
racc は、文法の記述方法は若干異なりますが、yacc と同様のパーサジェネレータです。 racc についての解説書 [7] も存在し、すでに確固とした地位を築いていると思います。 ランタイム時のパース速度の向上などのために、racc で生成したパーサは、いくつか ライブラリを必要とします。 しかし、ruby-1.8 では、そのようなランタイムライブラリはすでに含まれています。 このため、raccを用いてアプリケーションの開発を行っても、そのアプリケーションの ユーザがraccをインストールする必要はありません。 文法そのものの記述方法は BNF で記述します。
ここでは、abnf の場合と同様に、四則演算を含む算術式を計算する計算機クラス Calcを作成します。 コンパイラやインタプリタの作成では、一般的に、パーサによって解析木という 入力データに対する内部表現を一度作成しますが、ここでは、直接計算を行う ことにします。この Calc クラスを生成する racc の文法ファイルが、以下の sample_racc_1.racc です。
class Calc
token NUM
prechigh
left '*' '/'
left '+' '-'
preclow
rule
expr : expr '+' expr { result = val[0] + val[2] }
| expr '-' expr { result = val[0] - val[2] }
| expr '*' expr { result = val[0] * val[2] }
| expr '/' expr { result = val[0] / val[2] }
| prim { result = val[0] }
prim : NUM { result = val[0] }
| '(' expr ')' { result = val[1] }
end
---- inner
def parse(str)
@tokens = str.split(/([\(\)\+\-\*\/])/).select{|c| !c.nil? && c!=""}
do_parse()
end
def next_token()
token = @tokens.shift()
case token
when /^\d+\z/
return [:NUM, token.to_i]
when nil
return nil
else
return [token, nil]
end
end
’—- inner’ という行より後ろには、Calc クラス内のコードを書きます。 parse メソッドは、与えられた算術式の計算を行うためのメソッドです。 まず式を分解してトークンの列を生成しています。トークンとは、構文解析 における単位データです。 そして、そのトークン列からトークンを逐次取り出すものが next_token メソッド です。 Calc クラスは、next_token メソッドにより取り出されるトークン列に対して、 構文解析を行います。 このような、トークンを取り出すメソッドは、racc で予め決まっていますが、 ここでは、その一つである next_token メソッドを定義しました。 方法は他にもありますが、詳しくは racc のマニュアルを参照してください。
次に、この文法ファイルから、Calc クラスを生成します。このためには、次の コマンドを用います。ここで、’-o’ というオプションによって出力ファイルを 指定します。
sample_racc_1.rb のファイル中に、Calc クラスのコードがあります。 このため、Calc クラスを実体化して、計算を行うためのコードは次の通りです。
require 'sample_racc_1'
calc = Calc.new()
p calc.parse("3+(2-1)")
さて、abnf の例題 sample_abnf_1.rb では、eval() (10行目) を用いて算術式の 計算を行っていました。 eval() は、便利な機能ですが、使い方を間違えると脆弱性の原因となることも少 なくありません。そこで、sample_racc_1.racc によって生成されたパーサを用いて 計算を行うように書き換えたものが以下の sample_racc_3.rb です。
require 'abnf'
require 'sample_racc_1'
fmt = <<EOS
expr = prim 0*(op prim)
prim = ["("] num [")"]
num = 1*("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
op = "+" | "-" | "*" | "/"
EOS
reg = ABNF.regexp(fmt)
str = "3+(2-1)は4です。33(23)は33と(23)に区別されます。"
calc = Calc.new()
puts( str.gsub(reg){|expr| calc.parse(expr)} )
このように、文字列から算術式に一致する部分文字列を abnf を用いて抽出し、その 文字列を racc で生成したパーサを用いて構文解析と計算を行うことができました。
tdp4r は、単純な再帰下降解析(recursive descent parsing)を行うパーサを動的に 作成するためのライブラリです。 再帰下降解析の特徴の一つに、文法とメソッドを1:1に対応できる点があります。 つまり、ある文法を用いることが、あるメソッドを呼び出すことに対応しています。 この特徴を活かして、文法を Ruby のプログラムとして記述できます。 このため、実行時に Ruby を用いて文法を制御できるなどの柔軟性があります。 文法は、JavaCC [3] と類似した書式によって 記述します。
一般的に、再帰下降パーサでは、A = A b | a のような左再帰を含む文法を記述でき ません。しかし、このような文法により生成できる文字は、abbbbbbb… となります。 つまり、繰り返し記号「*」を導入すると、A = a b * という文法を記述することと等 しくなります。 tdp4r でも、繰り返し記号を用いた文法を記述できますので、繰り返しを用いて文法 を記述し、その文法に従って算術式の計算を行うことにします。
require 'tdp'
class MyCalc
include TDParser
def expr1
rule(:expr2) - ((token("+")|token("-")) - rule(:expr2))*0 >> proc{|x|
n = x[0]
x[1].each{|y|
case y[0]
when "+"; n += y[1]
when "-"; n -= y[1]
end
}
n
}
end
def expr2
rule(:prim) - ((token("*")|token("/")) - rule(:prim))*0 >> proc{|x|
n = x[0]
x[1].each{|y|
case y[0]
when "*"; n *= y[1]
when "/"; n = n / y[1]
end
}
n
}
end
def prim
token(/\d+/) >> proc{|x| x[0].to_i } |
token("(") - rule(:expr1) - token(")") >> proc{|x| x[1] }
end
def parse(str)
tokens = str.split(/(?:\s+)|([\(\)\+\-\*\/])/).select{|x| x != ""}
expr1.parse(tokens)
end
end
calc = MyCalc.new
puts("5-2*(5-2*2) = " + calc.parse("5-2*(5-2*2)").to_s())
tdp4r では、文法を Ruby のコードとして記述します。 このために、まず Calc クラスを用意して、TDParser モジュールをインクルードします。 TDParser モジュールには、rule や token などの文法を定義するために利用するメソッド が定義されています。 rule メソッドによって、ルールの適用を行い、token メソッドによって、トークン を一つ読み取ります。 ここで、ルールは、構文解析においては、別の構文定義のことであり、実装において は、別のメソッドに相当します。 rule と token を連接「-」、選択「|」、繰り返し「*」の記号を用いて結合させて、 ルールを定義します。 このような仕組みによって、文法の定義を行うため、構文解析を行うコードは実行時 に作成されます。このため、動的にルールを取り替えるなどが可能です。
解析されたデータを処理は、「>>」の後ろの Proc オブジェクトにより行います。 このような処理の記述は、実はルール中に埋め込むようにも書くことができますが、 文法そのものとの区別が付きにくくなる場合があります。
require 'sample_tdp4r_1'
class MyCalc
def expr1
n = nil;
(rule(:expr2) >> proc{|x| n = x[0] }) -
((token("+")|token("-")) - rule(:expr2) >> proc{|x|
case x[0]
when "+"; n += x[1]
when "-"; n -= x[1]
end
n
})*0 >> proc{ n }
end
def expr2
n = nil;
(rule(:prim) >> proc{|x| n = x[0] }) -
((token("*")|token("/")) - rule(:prim) >> proc{|x|
case x[0]
when "*"; n *= x[1]
when "/"; n /= x[1]
end
n
})*0 >> proc{ n }
end
end
calc = MyCalc.new
puts("5-2*(5-2*2) = " + calc.parse("5-2*(5-2*2)").to_s())
また、サンプルコードから分かる通り、tdp4r では演算子の優先順位は、文法を工夫す ることによって実現しなければなりません。このあたりは、racc に比べると非常に不便 な点となるでしょう。 また、解析速度も racc より明らかに遅くなります。 詳細な議論は RLR ではしませんが、少しでも速くするために次のように一度構成した 文法を再利用できるようにする方法があります。
require 'sample_tdp4r_1'
class FastCalc < MyCalc
def expr1
@expr1 ||= super()
end
def expr2
@expr2 ||= super()
end
def prim
@prim ||= super()
end
end
calc = FastCalc.new
puts("5-2*(5-2*2) = " + calc.parse("5-2*(5-2*2)").to_s())
syntax.rb は、tdp4r とほとんど狙いは同じライブラリです。 文法を Ruby のコードとして記述できます。 特徴的なことは、トークンへの分解6も syntax.rb によって行うことができ、また、ルールの定義がオブジェクトのみ で実現できることです。
require 'syntax'
INF = +1.0/0
LOOP0 = (0..INF)
LOOP1 = (1..INF)
num = (("0".."9")*LOOP1).qualify{|x| x.to_s().to_i()}
expr = Syntax::Pass.new()
prim = num.qualify{|x| x[0]} | ("(" + expr + ")").qualify{|x| x[1]}
expr2 = (prim + (("*" | "/") + prim)*LOOP0).qualify{|x|
val = x[0]
x[1].each{|y|
case y[0]
when "*"; val *= y[1]
when "/"; val /= y[1]
end
}
val
}
expr << (expr2 + (("+" | "-") + expr2)*LOOP0).qualify{|x|
val = x[0]
x[1].each{|y|
case y[0]
when "+"; val += y[1]
when "-"; val -= y[1]
end
}
val
}
p(expr === RandomAccessStream.new("5-2*2"))
reg は、正規表現によるパターンマッチングを、Ruby の基本的なデータ構造に適用 できるようにするライブラリです。 多くの人は、文字列に対して部分文字列を抽出・置換する場合、正規表現を利用する ことが多いと思います。これは、正規表現が適度な手軽さを提供するためです。 同様に、reg では、基本的なデータ構造に対しても、この手軽さを実現しようと試み ています。
将来的には、パターンマッチの過程において、部分的にマッチしたデータを他のデータ に置き換えることなども視野に入れているようです。 このような置換を伴うパターンマッチが行えると、パターンによって処理を行うべき データを検出し、さらにその検出したデータを逐次的に他のデータに置換できます。 このような処理は、abnf と tdp4r/syntax.rb を組み合わせたような処理に似ています。 現在は、そのような置換を行う機能は実装されていないので、パターンマッチを用いた データの妥当性チェックの方法を次節にて示します。
データの構造が、妥当であるかどうかのチェックを行うことを考えます。 例えば、トークンの列を考えた場合、配列中に “” の文字を含めたくない 場合を考えます。このような場合、配列 ary に対して
というコードを実行することによって “” を取り除くことが可能です。 また、
によってすべての配列要素が “” ではないことをチェックできます。 一方で、このようなチェックを reg を用いて記述すると次の通りです。
reg では、+[] によって基本データの列(シーケンス)を表す正規表現を表し、+0 によって、 0回以上の繰り返しを表します。 そして、=== によって正規表現にマッチした要素をグループ化して返します。 例えば、
は、[ [ [“a”,”b”,”c”] ] ] が返りますが、
は、[ [ [“a”] ], [ [“b”, “c”] ] ] が返ります。つまり、最初の String にマッチする グループと、String の繰り返しにマッチするグループに分かれたことになります。 また、=== によってマッチングを行っているので、case 文の when 節で利用することもできます。
ASN.1 は、Abstract Syntax Notation One というデータ構造 を記述するための文法です。 SSL, SNMP, LDAP と呼ば れるプロトコルにおいて、メッセージデータを定義するために利用されています。 OpenSSL::ASN1 は、そのような ASN.1 に従ってデータを構成するためのライブラリで、 OpenSSL 拡張モジュールの一部として提供されています。 ただし、ASN.1 の構文を解釈して、その構文に従うデータを処理するコードを自動生成 しません。開発者が、データを構文に従って組み立てるための、基本的な枠組だけを 提供しています。
データ構造をエンコーディングする方法として BER (Basic Encoding Rules) , CER (Canonical Encoding Rules), DER (Distinguished Encoding Rules) など複数 の方法があります。OpenSSL::ASN1 では、DER エンコーディングのみがサポートされて います。
Windows をご利用の方であれば、[インターネットオプション]->[コンテンツ]->[証明書] のウィンドウから、一つ証明書を選択し、エクスポートを行うことができます。 このとき、エンコーディング方法として DER を選択できます。 そこで、このような DER によりエンコーディングされた電子証明書を OpenSSL::ASN1 を 用いて閲覧します。7 ここでは DER 形式でエンコーディングされた証明書を foo.der という名前のファイル とします。
まず、証明書(foo.cer) の中身を解析するためには、その構造を知る必要があります。 電子証明書の構造は、RFC:3280 で記述されています。 RFC:3280 の 4.1 “Basic Certificate Profile” を見ると以下の文法が掲載され ています。これが ASN.1 で記述されたデータ構造の定義です。
ここで定義していることは、証明書の中身が、まず tbsCertificate, signatureAlgorithm, signatureValue の3つのデータから構成されていることを表しています。 そして、それぞれのデータは、それぞれ TBSCertificate, AlgorithmIdentifier, BIT STRING という構造を持つことも表しています。TBSCertificate の構造は多少複雑ですので、簡単な AlgorithmIdentifier の定義を参考として挙げます。これは、4.1.1.2 節において記載されて います。
このような、データ構造を読み込み、再び再構成してファイルへ出力するプログラムが 以下の sample_asn1_1.rb です。
require 'openssl'
require 'pp'
include OpenSSL
data = ASN1.decode(File.open(ARGV[0], 'rb'){|f| f.read()})
tbsCertificate, signatureAlgorithm, signatureValue = data.value
pp tbsCertificate
pp signatureAlgorithm
pp signatureValue
data = ASN1::Sequence [
tbsCertificate,
signatureAlgorithm,
signatureValue,
]
File.open(ARGV[1], 'wb'){|f| f.write(data.to_der())}
5 行目でファイルから証明書のデータを読み込み DER 形式のデコードを行っています。 そして、7 行目では、データ構造の定義に従って、それらを tbsCertificate, signatureAlgorithm, signatureValue の3つのデータに分割しています。 次に、12 行目では、これら 3 つを再び一つの証明書として構成しています。 一見すると、ASN.1 の定義に似ていることが分かります。 そして、17 行目では、再構成したデータを to_der により DER 形式でエンコードを行い、 ファイルへ出力しています。 このため、このプログラムを例えば ruby sample_asn1_1.rb foo.der bar.der として 実行すると、foo.der と中身が等しい bar.der というファイルができているはずです。 このことは、md5sum や cmp などのコマンドで確認できます。
以上のように、OpenSSL::ASN1 では、データの構成と、エンコードとデコードが主な役割 です。今後、ASN.1 の文法を記述することによって、その文法に従うデータを処理するため のコードを生成できることを期待します。 例えば、Certificate の例ですと、以下のように Internal DSL 風に ASN.1 を記述でき、 自動的にデータを読み込むことができると、さらに使い易いと思います。 もちろん、ASN.1 を記述言語として、以下のような Internal DSL のコードを生成すると いうことも可能でしょう。
今回は、正規表現や構文解析に関係するデータ処理のためのツールとライブラリ を紹介しました。 abnf は、複雑になりがちな正規表現の記述を支援できます。 racc は、対象データの文法(つまり、構造)に沿って処理方法を簡潔に記述することがで きます。 tdp4r と syntax.rb は、racc と同様の目的で使えますが、文法を Ruby のコードとして 記述できます。このため、Ruby の記述力を活かした動的な文法定義などが興味深いです。 reg と OpenSSL::ASN1 は、データ処理という観点からは、今後の発展が非常に楽しみな ライブラリです。
[1] DSL(Domain Specific Language) については、 「Domain Specific Language」 に簡潔な説明があります。
[2] 「Domain-Specific Language (DSL) Tools」 には、マイクロソフトが提供する DSL 関連のツールを見ることができます。
[3] 「JavaCC Grammar Repository」 には、様々な JavaCC の文法ファイルを見つけることができます。 tdp4r や syntax.rb などを扱う場合の参考になります。
[4] 「コンパイラ」 は、日本語で書かれたコンパイラの教科書です。 しかし、ツールやライブラリを使う限りにおいて内容を知る必要はありません。
[5] 「コンパイラの理論と実現」 では、 実際に C– という例題を基に、簡単にコンパイラの構成方法を説明しています。
[6] ASN.1 Compiler は、 オープンソースの ASN.1 コンパイラです。
[7] 「Rubyを256倍使うための本 無道編」 は、racc を用いたインタプリタの作成について解説しています。
著者(立石)は、ソフトウェアの研究開発職に従事しています。
有名なものに、正規文法、文脈自由文法 、LALR(1) 文法、LL(k) 文法 などの文法のクラスがあります。 ↩
LR解析、LALR(1)解析、再帰下降解析などの解析方法があります。 ↩
これらは、パーサやXMLというドメインに特化した言語ですが、ビジネスドメインを記述するものも同様に DSL です。 ↩
[[RFC:2373]]のAPPENDIX B ↩
[[RFC:2393]]のAPPENDIX A ↩
一般的に字句解析と言います ↩
Linux や *BSD をお使いの方も、適当な DER によりエンコーディングされた電子証明書をご用意ください。(編注: 適当な証明書が無い場合は CAcert.org からダウンロードすると良いでしょう) ↩