読者です 読者をやめる 読者になる 読者になる

k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

Volcanus_CsvとSymfony-FinderでローテートされたApacheログをLTSVフォーマットに集約してみる

CSVファイルの入出力用ライブラリ Volcanus_Csv を使ったシリーズ記事です。

話題の LTSV を扱ってみようと思い立ったものの、お世話になっているレンタルサーバGehirn RS2ではApacheのログフォーマット変更は難しい…。

そんなわけで、ローテート済みのcombinedフォーマットなHTTPアクセスログを、SymfonyのFinderコンポーネントでまとめて、拙作ライブラリ Volcanus_CsvでLTSVフォーマットに変換してみました。

Volcanus_CsvCSV と名乗ってはいますが、ナントカ 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」って言いたかっただけですが、いくつか今後の課題も見つかりましたので良しとしておきます。