Volcanus_CsvとSymfony-FinderでローテートされたApacheログをLTSVフォーマットに集約してみる
CSVファイルの入出力用ライブラリ Volcanus_Csv を使ったシリーズ記事です。
話題の LTSV を扱ってみようと思い立ったものの、お世話になっているレンタルサーバGehirn RS2ではApacheのログフォーマット変更は難しい…。
そんなわけで、ローテート済みのcombinedフォーマットなHTTPアクセスログを、SymfonyのFinderコンポーネントでまとめて、拙作ライブラリ Volcanus_CsvでLTSVフォーマットに変換してみました。
Volcanus_Csv は CSV と名乗ってはいますが、ナントカ Separated Values なら何でも扱える作りになってます。
ただし、HTTPアクセスログにおけるLTSVのようにエスケープ不要なケースを想定していなかったので、ちょっとした改修が必要となりました。
参考:LTSV のもうひとつのメリット、あるいは、プログラムでログを出力する際に気をつけるべきこと - kazuhoのメモ置き場
composer.jsonはこんな感じで。
{ "require": { "volcanus/csv": "dev-develop", "volcanus/configuration": "dev-master", "symfony/finder" : "dev-master", "silex/silex": "1.0.*", "smarty/smarty": "dev-trunk" }, "minimum-stability": "dev" }
volcanus/csv はLTSVへの対応を進めている develop ブランチを指定します。
また、volcanus/csv では設定値管理用のライブラリ volcanus/configuration に依存するようになってますので、これも指定します。
そしてバッチ的な処理ではありますが、動作確認も兼ねて Silex + Smarty3 で画面を作ることにしました。
エントリスクリプトは今回は単機能の画面になりますので、割と単純になりました。
Gehirn RS2のhttpdログファイルはユーザーのホームディレクトリ以下に ~/apache_log/{ホスト名}/access.log.{1-7} というファイル名でローテートされたログファイルが保存されます。
SymfonyのFinderコンポーネントは2.2から path() と name() でGlobと正規表現が、 in() でGlobが使えるようになっていて、だいぶ楽できますね。
New in Symfony 2.2: Finder improvements - Symfony
Finderのメソッドはこんな感じです。
<?php // ログファイル抽出用 Iterator $app['file_iterator'] = $app->protect(function() { return \Symfony\Component\Finder\Finder::create() ->files() ->in(sprintf('%s/../../apache_log/kholy.gehirn.ne.jp', __DIR__)) ->name('access.log.*') ->sort(function(\SplFileInfo $file1, \SplFileInfo $file2) { return ( intval(pathinfo($file1->getFilename(), PATHINFO_EXTENSION)) < intval(pathinfo($file2->getFilename(), PATHINFO_EXTENSION)) ) ? 1 : -1; }) ->getIterator() ; });
sort()がちょっと分かりづらいのですが、時系列順に上から下にログを集約できるよう、ローテートされたログファイルの拡張子(access.log.{1-7}の数値部分)で降順にソートしてファイルを返しています。
なお pathinfo()関数 を使っているのは、PHPのバージョンの都合で SplFileInfo::getExtension() が使えなかったからです。
(ちなみに Gehirn RS2のPHP5.3.5環境だと name() の正規表現マッチはうまく機能しませんでした…なので今回はGlobにしています。)
Volcanus\Csv\Writer の設定と処理部分はこんな感じになりました。(2013-03-05更新)
<?php // LTSV出力用 Volcanus\Csv\Writer $app['ltsv_writer'] = $app->protect(function(\Iterator $iterator) { $writer = new \Volcanus\Csv\Writer(array( 'delimiter' => "\t", 'enclosure' => '', // 囲み文字は空 'escape' => '', // エスケープ文字は空 'enclose' => false, // 強制的に囲まない 'newLine' => "\n", // 改行はLF 'inputEncoding' => 'UTF-8', 'outputEncoding' => 'UTF-8', 'writeHeaderLine' => false, // ヘッダ行を出力しない 'responseFilename' => 'accesslog.ltsv', // レスポンスのファイル名 )); $pattern = '/\A(\S+) (\S+) (\S+) (\[.*?\]) "\(\(?:\\[\\\"]|.\)*?\)" (\S+) (\S+) "\(\(?:\\[\\\"]|.\)*?\)" "\(\(?:\\[\\\"]|.\)*?\)"/'; $writer->fields(array( array(function($fields) { return sprintf('host:%s' , $fields[1]); }), array(function($fields) { return sprintf('ident:%s' , $fields[2]); }), array(function($fields) { return sprintf('user:%s' , $fields[3]); }), array(function($fields) { return sprintf('time:%s' , $fields[4]); }), array(function($fields) { return sprintf('req:%s' , $fields[5]); }), array(function($fields) { return sprintf('status:%s' , $fields[6]); }), array(function($fields) { return sprintf('size:%s' , $fields[7]); }), array(function($fields) { return sprintf('referer:%s', $fields[8]); }), array(function($fields) { return sprintf('ua:%s' , $fields[9]); }), )); $writer->file = new \SplFileObject('php://temp', 't+'); iterator_apply($iterator, function(\Iterator $iterator, $writer, $pattern) { $current = $iterator->current(); if ($current instanceof \SplFileInfo) { $file = $current->openFile('r'); while (!$file->eof()) { if (preg_match($pattern, $file->fgets(), $fields)) { $writer->file->fwrite($writer->buildContentLine($fields)); } } } return true; }, array($iterator, $writer, $pattern)); return $writer; });
$writer->fields() の部分が Volcanus_Csv の特徴で、フイールド値の定義にコールバックを指定できますので、LTSVのラベルを付けて返すよう設定しています。(Gehirn RS2のPHPは5.3なのでshort syntax使えません…)
また、$writer->file でテンポラリファイル用の SplFileObject をセットするのは、出力時の定番になっています。
iterator_apply()関数で、Finderが返すSplFileInfoから読み込んだ1行分のログを配列またはオブジェクトに変換して、$writer->buildContentLine() で1行分のLTSVを生成して、テンポラリファイルに書き込むという流れです。
(iterator_apply は foreach よりも遅い - Sarabande.jp という話もありますが)
むしろ別のイテレータとして実装しておいてFinder::append()を使うところかも知れませんが、今回はパスしました。
(まだ配列脳から抜け切れておらず、イテレータ使いこなせていません…。)
これらを利用する部分のエントリスクリプトです。
<?php namespace Acme; $loader = include __DIR__ . '/../../vendor/autoload.php'; use Silex\Application; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; $app = new Application(); $app->register(new \Silex\Provider\UrlGeneratorServiceProvider()); // Smarty $app['smarty'] = $app->share(function(Application $app) { $smarty = new \Smarty(); $smarty->compile_dir = sys_get_temp_dir(); $smarty->force_compile = $app['debug']; $smarty->escape_html = true; return $smarty; }); // GET:files $app->get('/', function(Application $app, Request $request) { $data = new \StdClass(); $data->pathToFiles = $app['url_generator']->generate('files'); $data->pathToAggregation = $app['url_generator']->generate('aggregation'); $data->dateTimeFormat = '%Y-%m-%d %H:%M:%S'; $data->title = 'Apache combined log -> combined_ltsv 変換 + 集約'; $data->files = $app['file_iterator'](); $data->source = highlight_file(__FILE__, true); $app['smarty']->assign('this', $data); return new Response( $app['smarty']->fetch(sprintf('string:%s', <<<'TEMPLATE' <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <title>{$this->title}</title> <style> .number { text-align:right; } </style> </head> <body> <h1>{$this->title}</h1> <h2>ファイル一覧</h2> <table> <thead> <tr> <th>ファイルパス</th> <th>最終更新</th> <th>サイズ</th> </tr> </thead> <tbody> {foreach $this->files as $file} <tr> <td>{$file->getRealPath()}</td> <td>{$file->getMTime()|date_format:$this->dateTimeFormat}</td> <td class="number">{$file->getSize()|number_format}</td> </tr> {/foreach} </tbody> </table> <form method="get" action="{$this->pathToAggregation}"> <input type="submit" value="ダウンロード" /> </form> <hr /> <h2>ソース</h2> <pre>{$this->source nofilter}</pre> </body> </html> TEMPLATE )), 200 ); })->bind('files'); // GET:aggregation $app->get('/aggregation', function(Application $app, Request $request) { $writer = $app['ltsv_writer']($app['file_iterator']()); return $app->stream( function() use ($writer) { $writer->flush(); }, 200, $writer->buildResponseHeaders() ); })->bind('aggregation'); $app->run();
ログファイルの一覧を表示するためのルーティング GET / と、そのログファイルを Volcanus_Csvを使ってLTSV形式で集約したファイルを返すためのルーティング GET /aggregation を定義しています。
Smartyを使うのは前者のみですが、テンプレートファイルを分けるのも面倒なのでNewDocで書いてます。
ローカルでの開発中はサブディレクトリになるので、URLジェネレータも使ってます。(今回はSmartyプラグインとして実装せず、テンプレート変数に直接割り当ててますが)
特に何するというわけでもなく単に「LTSV」って言いたかっただけですが、いくつか今後の課題も見つかりましたので良しとしておきます。