みどりねこ日記

よくわからないけど、頑張りますよ。

Parsec を使って Apache のログファイルをパースしてみる

この記事について

この記事は、 Erik さんのvariadic.me - Adventures in Parsec - Part 1を翻訳したものです。

導入

Haskell について知っていればいるほど良いですが、あなたが熱心な Haskeller であるならこの記事は少々退屈に感じるでしょう。

もし Haskell の基本について学びたいのであれば、Learn You A Haskell (邦訳:すごいHaskellたのしく学ぼう)をおすすめします。この本は何度も読みたくなります!また、どのようにプログラムを組んでいくかを詳しく知りたい人は、 Real World Haskell を読みましょう。実践に生かせる練習問題やアドバイスが沢山載っています。私はこのコードを書くとき、この本を頼りに作業をしていました。

Apache はおそらく世界で最も普及しているウェブサーバでしょう。多くのウェブサーバと同じように、様々な情報とともに各リクエストのログをとっています。大きなIT企業はこうした情報を活用しているでしょうが、私達のほとんどはこのログを増やすだけで放置しています。このログを使って何か素晴らしい情報を引き出してみましょう!この記事で取り扱うログフォーマットは Apache combined log format です。これはサンプルです。

192.168.1.80 - - [18/Feb/2011:20:21:30 +0100] "GET / HTTP/1.0" 503 2682 "-" "-"

もしあなたのログファイルが違うフォーマットであるなら、多少の調節が必要です。フィールドは順に:クライアントの IP アドレス、クライアントのアイデンティティ、ユーザ ID、リクエストを受け取った日時、リクエスト、ステータスコード、バイト、 "referer" HTTP リクエストヘッダ、 "user-agent" HTTP リクエストヘッダです。これらの情報が確実に保持できるように、ログのそれぞれの値をパースして、データ型にまとめましょう。さて、定義してみましょう!

data LogLine = LogLine {
	  getIP :: String
	, getIdent :: String
	, getUser :: String
	, getDate :: String
	, getReq :: String
	, getStatus :: String
	, getBytes :: String
	,getRef :: String
	, getUA :: String
} deriving (Ord, Show, Eq)

レコードシンタックスを使ってアクセサを用意しました。また、便利のためにこれらをOrd, Show, Eq 型クラスのインスタンスにしました。

次は、行のパースを実際にパースしましょう。以前、このスクリプトを Python が書いた時、正規表現を用いてパースをしました。すると、コーディングに時間がかかり、また使っている間はずっとバグに出会う羽目になりました。しかし、 Haskell はもっとよい方法を提示してくれています。正規表現を用いるより、 Parsec ライブラリのパーサコンビネータを使いましょう。 小さなパーサをつなげて大きなパーサにするという Parsec のアイデアは、対象物が何であるかを一歩一歩確実に定義していくことを可能にしています。例を見ながらが理解しやすいと思います。

Parsec

上にあるログの例の一行をみると、それぞれの値がスペースで区切られています。しかし、いくつかの値はクォートや角括弧で囲まれた値の中にスペースを含んでいます。ですので、私たちは3つの種類の値をパースすることになります。まずは一番簡単な、スペースを含んでいない値のパーサを定義してみましょう。

plainValue :: Parser String
plainValue = many1 (noneOf " \n")

型シグナチャは、「 plainValue の結果は、 Parser モナドに包まれた String 」と読めます。 Parsec がモナドであることの利点のひとつとして、他のパーサの中でも do 記法で使えるようになることです。 plainValue パーサに話を戻しますが、 最初に many1 を使っています。これは、パーサをひとつとって、それを1つ以上適用し、最終的に値のリストを返すアクションです。私たちのケースでは、スペースでない限り文字を消費したいのです。なぜなら、 Apache のログ行の値はスペースで区切られているからです。このため、文字リストを受け取って、文字が渡されたリストでない場合、その文字をひとつ消費する noneOf を用いています。

これらをつなげて、 many1 (noneOf " \n") はスペースまたは改行に当たるまで文字を消費します。素晴らしい!

次は、角括弧で囲まれた値とクォートで囲まれた値をどうするかを考えましょう。どちらもその地の中にスペースを含む可能性があるため、これらのために特別にパーサを作る必要があります。

brancketedValue :: Parser String
brancketedValue = do
	char '['
	content <- many (noneOf "]")
	char ']'
	return content

quotedValue :: Parser String
quotedValue = do
	char '"'
	content <- many (noneOf "\"")
	char '"'
	return content

これらは do 記法で書かれたパーサです。簡潔なだけでなく、ログ行のフォーマットを表しています! brancketedValue をみてみましょう:まず、左端にある括弧を消費しています。そのあと、 many1 (noneOf "]") の結果を content という名に束縛します。先ほどの many1 (noneOf " \n") のように、与えられた文字列に該当しない限り、文字を消費していきます。最終的に、最後の文字である右角括弧を消費し、 return によって結果を Parser モナドで包みます。

これですべての値をパースできるようになったので、これらのパーサをどのように使って1行をパースするかを定義しましょう。それぞれの結果を LogLine データ型にまとめたいので、それぞれのパーサの結果を対応する値に結びつける必要があります。さあ、やってみましょう!

logLine :: Parser LogLine
logLine = do
	ip <- plainValue
	space -- スペースをパースしてその結果を捨てる
	ident <- plainValue
	space
	user <- plainValue
	space
	date <- brancketedValue
	space
	req <- quotedValue
	space
	status <- plainValue
	space
	bytes <- plainValue
	space
	ref <- quotedValue
	space
	ua <- quotedValue
	return $ LogLine ip ident user date req status bytes ref ua

多少余分な記述がありそうですが、このパーサを解釈するのは容易です。信じられないもしれませんが、これで終わりです。このスクリプトは Apache combined log スタイルの行をパースします。では実際に例をパースしてみましょう!

testLine = "192.168.1.80 - - [18/Feb/2011:20:21:30 +0100] \"GET / HTTP/1.0\" 503 2682 \"-\" \"-\""

main = case parse logLine "(test)" testLine of
	Left err -> print err
	Right res -> print res

parse は入力に対するパーサの結果を Parsec モナドから引き出し、エラーまたは結果を返します。結果は以下のようになります。

LogLine { getIP = "192.168.1.80"
	, getIdent = "-"
	, getUser = "-"
	, getDate = "18/Feb/2011:20:21:30 +0100"
	, getReq = "GET / HTTP/1.0"
	, getStatus = "503"
	, getBytes = "2682"
	, getRef = "-"
	, getUA = "-"
	}

サンプルコードはここにあります。